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, is_sovereign_path_request,
22 normalize_workspace_path,
23};
24use crate::agent::recovery_recipes::{
25 attempt_recovery, plan_recovery, preview_recovery_decision, RecoveryContext, RecoveryDecision,
26 RecoveryPlan, RecoveryScenario, RecoveryStep,
27};
28use crate::agent::routing::{
29 all_host_inspection_topics, classify_query_intent, is_capability_probe_tool,
30 is_scaffold_request, looks_like_mutation_request, needs_computation_sandbox,
31 preferred_host_inspection_topic, preferred_maintainer_workflow, preferred_workspace_workflow,
32 DirectAnswerKind, QueryIntentClass,
33};
34use crate::agent::tool_registry::dispatch_builtin_tool;
35use crate::agent::compaction::{self, CompactionConfig};
37use crate::ui::gpu_monitor::GpuState;
38
39use serde_json::Value;
40use std::sync::Arc;
41use tokio::sync::{mpsc, Mutex};
42#[derive(Clone, Debug, Default)]
45pub struct UserTurn {
46 pub text: String,
47 pub attached_document: Option<AttachedDocument>,
48 pub attached_image: Option<AttachedImage>,
49}
50
51#[derive(Clone, Debug)]
52pub struct AttachedDocument {
53 pub name: String,
54 pub content: String,
55}
56
57#[derive(Clone, Debug)]
58pub struct AttachedImage {
59 pub name: String,
60 pub path: String,
61}
62
63impl UserTurn {
64 pub fn text(text: impl Into<String>) -> Self {
65 Self {
66 text: text.into(),
67 attached_document: None,
68 attached_image: None,
69 }
70 }
71}
72
73#[derive(serde::Serialize, serde::Deserialize)]
74struct SavedSession {
75 running_summary: Option<String>,
76 #[serde(default)]
77 session_memory: crate::agent::compaction::SessionMemory,
78 #[serde(default)]
80 last_goal: Option<String>,
81 #[serde(default)]
83 turn_count: u32,
84}
85
86pub struct CheckpointResume {
89 pub last_goal: String,
90 pub turn_count: u32,
91 pub working_files: Vec<String>,
92 pub last_verify_ok: Option<bool>,
93}
94
95pub fn load_checkpoint() -> Option<CheckpointResume> {
98 let path = session_path();
99 let data = std::fs::read_to_string(&path).ok()?;
100 let saved: SavedSession = serde_json::from_str(&data).ok()?;
101 let goal = saved.last_goal.filter(|g| !g.trim().is_empty())?;
102 if saved.turn_count == 0 {
103 return None;
104 }
105 let mut working_files: Vec<String> = saved
106 .session_memory
107 .working_set
108 .into_iter()
109 .take(4)
110 .collect();
111 working_files.sort();
112 let last_verify_ok = saved.session_memory.last_verification.map(|v| v.successful);
113 Some(CheckpointResume {
114 last_goal: goal,
115 turn_count: saved.turn_count,
116 working_files,
117 last_verify_ok,
118 })
119}
120
121#[derive(Default)]
122struct ActionGroundingState {
123 turn_index: u64,
124 observed_paths: std::collections::HashMap<String, u64>,
125 inspected_paths: std::collections::HashMap<String, u64>,
126 last_verify_build_turn: Option<u64>,
127 last_verify_build_ok: bool,
128 last_failed_build_paths: Vec<String>,
129 code_changed_since_verify: bool,
130 redirected_host_inspection_topics: std::collections::HashMap<String, u64>,
132}
133
134struct PlanExecutionGuard {
135 flag: Arc<std::sync::atomic::AtomicBool>,
136}
137
138impl Drop for PlanExecutionGuard {
139 fn drop(&mut self) {
140 self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
141 }
142}
143
144struct PlanExecutionPassGuard {
145 depth: Arc<std::sync::atomic::AtomicUsize>,
146}
147
148impl Drop for PlanExecutionPassGuard {
149 fn drop(&mut self) {
150 self.depth.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub enum WorkflowMode {
156 #[default]
157 Auto,
158 Ask,
159 Code,
160 Architect,
161 ReadOnly,
162 Chat,
165 Teach,
169}
170
171impl WorkflowMode {
172 fn label(self) -> &'static str {
173 match self {
174 WorkflowMode::Auto => "AUTO",
175 WorkflowMode::Ask => "ASK",
176 WorkflowMode::Code => "CODE",
177 WorkflowMode::Architect => "ARCHITECT",
178 WorkflowMode::ReadOnly => "READ-ONLY",
179 WorkflowMode::Chat => "CHAT",
180 WorkflowMode::Teach => "TEACH",
181 }
182 }
183
184 fn is_read_only(self) -> bool {
185 matches!(
186 self,
187 WorkflowMode::Ask
188 | WorkflowMode::Architect
189 | WorkflowMode::ReadOnly
190 | WorkflowMode::Teach
191 )
192 }
193
194 pub(crate) fn is_chat(self) -> bool {
195 matches!(self, WorkflowMode::Chat)
196 }
197}
198
199fn session_path() -> std::path::PathBuf {
200 if let Ok(overridden) = std::env::var("HEMATITE_SESSION_PATH") {
201 return std::path::PathBuf::from(overridden);
202 }
203 crate::tools::file_ops::hematite_dir().join("session.json")
204}
205
206fn load_session_data() -> (Option<String>, crate::agent::compaction::SessionMemory) {
207 let path = session_path();
208 if !path.exists() {
209 let mut memory = crate::agent::compaction::SessionMemory::default();
210 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
211 memory.current_plan = Some(plan);
212 }
213 return (None, memory);
214 }
215 let Ok(data) = std::fs::read_to_string(&path) else {
216 let mut memory = crate::agent::compaction::SessionMemory::default();
217 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
218 memory.current_plan = Some(plan);
219 }
220 return (None, memory);
221 };
222 let Ok(saved) = serde_json::from_str::<SavedSession>(&data) else {
223 let mut memory = crate::agent::compaction::SessionMemory::default();
224 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
225 memory.current_plan = Some(plan);
226 }
227 return (None, memory);
228 };
229 let mut memory = saved.session_memory;
230 if memory
231 .current_plan
232 .as_ref()
233 .map(|plan| plan.has_signal())
234 .unwrap_or(false)
235 == false
236 {
237 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
238 memory.current_plan = Some(plan);
239 }
240 }
241 (saved.running_summary, memory)
242}
243
244#[derive(Clone)]
245struct SovereignTeleportHandoff {
246 root: String,
247 plan: crate::tools::plan::PlanHandoff,
248}
249
250fn reset_task_files() {
251 let hdir = crate::tools::file_ops::hematite_dir();
252 let root = crate::tools::file_ops::workspace_root();
253 let _ = std::fs::remove_file(hdir.join("TASK.md"));
254 let _ = std::fs::remove_file(hdir.join("PLAN.md"));
255 let _ = std::fs::remove_file(hdir.join("WALKTHROUGH.md"));
256 let _ = std::fs::remove_file(root.join(".github").join("WALKTHROUGH.md"));
257 let _ = std::fs::write(hdir.join("TASK.md"), "");
258 let _ = std::fs::write(hdir.join("PLAN.md"), "");
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262struct TaskChecklistProgress {
263 total: usize,
264 completed: usize,
265 remaining: usize,
266}
267
268impl TaskChecklistProgress {
269 fn has_open_items(self) -> bool {
270 self.remaining > 0
271 }
272}
273
274fn task_status_path() -> std::path::PathBuf {
275 crate::tools::file_ops::hematite_dir().join("TASK.md")
276}
277
278fn parse_task_checklist_progress(input: &str) -> TaskChecklistProgress {
279 let mut progress = TaskChecklistProgress::default();
280
281 for line in input.lines() {
282 let trimmed = line.trim_start();
283 let candidate = trimmed
284 .strip_prefix("- ")
285 .or_else(|| trimmed.strip_prefix("* "))
286 .or_else(|| trimmed.strip_prefix("+ "))
287 .unwrap_or(trimmed);
288
289 let state = if candidate.starts_with("[x]") || candidate.starts_with("[X]") {
290 Some(true)
291 } else if candidate.starts_with("[ ]") {
292 Some(false)
293 } else {
294 None
295 };
296
297 if let Some(completed) = state {
298 progress.total += 1;
299 if completed {
300 progress.completed += 1;
301 }
302 }
303 }
304
305 progress.remaining = progress.total.saturating_sub(progress.completed);
306 progress
307}
308
309fn read_task_checklist_progress() -> Option<TaskChecklistProgress> {
310 let content = std::fs::read_to_string(task_status_path()).ok()?;
311 Some(parse_task_checklist_progress(&content))
312}
313
314fn plan_execution_sidecar_paths() -> Vec<String> {
315 let hdir = crate::tools::file_ops::hematite_dir();
316 ["TASK.md", "PLAN.md", "WALKTHROUGH.md"]
317 .iter()
318 .map(|name| normalize_workspace_path(hdir.join(name).to_string_lossy().as_ref()))
319 .collect()
320}
321
322fn merge_plan_allowed_paths(target_files: &[String]) -> Vec<String> {
323 let mut allowed = std::collections::BTreeSet::new();
324 for path in target_files {
325 allowed.insert(normalize_workspace_path(path));
326 }
327 for path in plan_execution_sidecar_paths() {
328 allowed.insert(path);
329 }
330 allowed.into_iter().collect()
331}
332
333fn should_continue_plan_execution(
334 current_pass: usize,
335 before: Option<TaskChecklistProgress>,
336 after: Option<TaskChecklistProgress>,
337 mutated_paths: &std::collections::BTreeSet<String>,
338) -> bool {
339 const MAX_AUTONOMOUS_PLAN_PASSES: usize = 6;
340
341 if current_pass >= MAX_AUTONOMOUS_PLAN_PASSES {
342 return false;
343 }
344
345 let Some(after) = after else {
346 return false;
347 };
348 if !after.has_open_items() {
349 return false;
350 }
351
352 match before {
353 Some(before) if before.total > 0 => {
354 after.completed > before.completed || after.remaining < before.remaining
355 }
356 Some(before) => after.total > before.total || !mutated_paths.is_empty(),
357 None => !mutated_paths.is_empty(),
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362struct AutoVerificationOutcome {
363 ok: bool,
364 summary: String,
365}
366
367fn should_run_website_validation(
368 contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
369 mutated_paths: &std::collections::BTreeSet<String>,
370) -> bool {
371 let Some(contract) = contract else {
372 return false;
373 };
374 if contract.loop_family != "website" {
375 return false;
376 }
377 if mutated_paths.is_empty() {
378 return true;
379 }
380 mutated_paths.iter().any(|path| {
381 let normalized = path.replace('\\', "/").to_ascii_lowercase();
382 normalized.ends_with(".html")
383 || normalized.ends_with(".css")
384 || normalized.ends_with(".js")
385 || normalized.ends_with(".jsx")
386 || normalized.ends_with(".ts")
387 || normalized.ends_with(".tsx")
388 || normalized.ends_with(".mdx")
389 || normalized.ends_with(".vue")
390 || normalized.ends_with(".svelte")
391 || normalized.ends_with("package.json")
392 || normalized.starts_with("public/")
393 || normalized.starts_with("static/")
394 || normalized.starts_with("pages/")
395 || normalized.starts_with("app/")
396 || normalized.starts_with("src/pages/")
397 || normalized.starts_with("src/app/")
398 })
399}
400
401fn is_repeat_guard_exempt_tool_call(tool_name: &str, args: &Value) -> bool {
402 if matches!(tool_name, "verify_build" | "git_commit" | "git_push") {
403 return true;
404 }
405 tool_name == "run_workspace_workflow"
406 && matches!(
407 args.get("workflow").and_then(|value| value.as_str()),
408 Some("website_probe" | "website_validate" | "website_status")
409 )
410}
411
412fn should_run_contract_verification_workflow(
413 contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
414 workflow: &str,
415 mutated_paths: &std::collections::BTreeSet<String>,
416) -> bool {
417 if matches!(workflow, "build" | "test" | "lint") {
419 return true;
420 }
421
422 match workflow {
423 "website_validate" => should_run_website_validation(contract, mutated_paths),
424 _ => true,
425 }
426}
427
428fn build_continue_plan_execution_prompt(progress: TaskChecklistProgress) -> String {
429 format!(
430 "Continue implementing the current plan. Read `.hematite/TASK.md` first, focus on the next unchecked items, and keep working until the checklist is complete or you hit one concrete blocker. There are currently {} unchecked checklist item(s) remaining.",
431 progress.remaining
432 )
433}
434
435fn purge_persistent_memory() {
436 let mem_dir = crate::tools::file_ops::hematite_dir().join("memories");
437 if mem_dir.exists() {
438 let _ = std::fs::remove_dir_all(&mem_dir);
439 let _ = std::fs::create_dir_all(&mem_dir);
440 }
441
442 let log_dir = crate::tools::file_ops::hematite_dir().join("logs");
443 if log_dir.exists() {
444 if let Ok(entries) = std::fs::read_dir(&log_dir) {
445 for entry in entries.flatten() {
446 let _ = std::fs::write(entry.path(), "");
447 }
448 }
449 }
450}
451
452fn apply_turn_attachments(user_turn: &UserTurn, prompt: &str) -> String {
453 let mut out = prompt.trim().to_string();
454 if let Some(doc) = user_turn.attached_document.as_ref() {
455 out = format!(
456 "[Attached document: {}]\n\n{}\n\n---\n\n{}",
457 doc.name, doc.content, out
458 );
459 }
460 if let Some(image) = user_turn.attached_image.as_ref() {
461 out = if out.is_empty() {
462 format!("[Attached image: {}]", image.name)
463 } else {
464 format!("[Attached image: {}]\n\n{}", image.name, out)
465 };
466 }
467 out = inject_at_file_mentions(&out);
470 out
471}
472
473fn inject_at_file_mentions(prompt: &str) -> String {
477 if !prompt.contains('@') {
479 return prompt.to_string();
480 }
481 let cwd = match std::env::current_dir() {
482 Ok(d) => d,
483 Err(_) => return prompt.to_string(),
484 };
485
486 let mut injected = Vec::new();
487 for token in prompt.split_whitespace() {
489 let raw = token.trim_start_matches('@');
490 if !token.starts_with('@') || raw.is_empty() {
491 continue;
492 }
493 let path_str =
495 raw.trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
496 if path_str.is_empty() {
497 continue;
498 }
499 let candidate = cwd.join(path_str);
500 if candidate.is_file() {
501 match std::fs::read_to_string(&candidate) {
502 Ok(content) if !content.is_empty() => {
503 const CAP: usize = 32 * 1024;
505 let body = if content.len() > CAP {
506 format!(
507 "{}\n... [truncated — file is large, use read_file for the rest]",
508 &content[..CAP]
509 )
510 } else {
511 content
512 };
513 injected.push(format!("[File: {}]\n```\n{}\n```", path_str, body.trim()));
514 }
515 _ => {}
516 }
517 }
518 }
519
520 if injected.is_empty() {
521 return prompt.to_string();
522 }
523 format!("{}\n\n---\n\n{}", injected.join("\n\n"), prompt)
525}
526
527fn compact_stale_reads(history: &mut Vec<ChatMessage>, path: &str) {
534 const MIN_SIZE_TO_COMPACT: usize = 800;
535 let stub = "[prior read_file content compacted — file was edited; use read_file to reload]";
536 let normalized = normalize_workspace_path(path);
537 let safe_tail = history.len().saturating_sub(2);
538 for msg in history[..safe_tail].iter_mut() {
539 if msg.role != "tool" {
540 continue;
541 }
542 let is_read_tool = matches!(
543 msg.name.as_deref(),
544 Some("read_file") | Some("inspect_lines")
545 );
546 if !is_read_tool {
547 continue;
548 }
549 let content = match &msg.content {
550 crate::agent::inference::MessageContent::Text(s) => s.clone(),
551 _ => continue,
552 };
553 if content.len() < MIN_SIZE_TO_COMPACT {
554 continue;
555 }
556 if content.contains(&normalized) || content.contains(path) {
558 msg.content = crate::agent::inference::MessageContent::Text(stub.to_string());
559 }
560 }
561}
562
563fn read_file_preview_for_retry(path: &str, max_lines: usize) -> String {
566 let content = match std::fs::read_to_string(path) {
567 Ok(c) => c.replace("\r\n", "\n"),
568 Err(e) => return format!("[could not read {path}: {e}]"),
569 };
570 let total = content.lines().count();
571 let lines: String = content
572 .lines()
573 .enumerate()
574 .take(max_lines)
575 .map(|(i, line)| format!("{:>4} {}", i + 1, line))
576 .collect::<Vec<_>>()
577 .join("\n");
578 if total > max_lines {
579 format!(
580 "{lines}\n... [{} more lines — use inspect_lines to see the rest]",
581 total - max_lines
582 )
583 } else {
584 lines
585 }
586}
587
588fn transcript_user_turn_text(user_turn: &UserTurn, prompt: &str) -> String {
589 let mut prefixes = Vec::new();
590 if let Some(doc) = user_turn.attached_document.as_ref() {
591 prefixes.push(format!("[Attached document: {}]", doc.name));
592 }
593 if let Some(image) = user_turn.attached_image.as_ref() {
594 prefixes.push(format!("[Attached image: {}]", image.name));
595 }
596 if prefixes.is_empty() {
597 prompt.to_string()
598 } else if prompt.trim().is_empty() {
599 prefixes.join("\n")
600 } else {
601 format!("{}\n{}", prefixes.join("\n"), prompt)
602 }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606enum RuntimeFailureClass {
607 ContextWindow,
608 ProviderDegraded,
609 ToolArgMalformed,
610 ToolPolicyBlocked,
611 ToolLoop,
612 VerificationFailed,
613 EmptyModelResponse,
614 Unknown,
615}
616
617impl RuntimeFailureClass {
618 fn tag(self) -> &'static str {
619 match self {
620 RuntimeFailureClass::ContextWindow => "context_window",
621 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
622 RuntimeFailureClass::ToolArgMalformed => "tool_arg_malformed",
623 RuntimeFailureClass::ToolPolicyBlocked => "tool_policy_blocked",
624 RuntimeFailureClass::ToolLoop => "tool_loop",
625 RuntimeFailureClass::VerificationFailed => "verification_failed",
626 RuntimeFailureClass::EmptyModelResponse => "empty_model_response",
627 RuntimeFailureClass::Unknown => "unknown",
628 }
629 }
630
631 fn operator_guidance(self) -> &'static str {
632 match self {
633 RuntimeFailureClass::ContextWindow => {
634 "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."
635 }
636 RuntimeFailureClass::ProviderDegraded => {
637 "Retry once automatically, then narrow the turn or restart LM Studio if it persists."
638 }
639 RuntimeFailureClass::ToolArgMalformed => {
640 "Retry with repaired or narrower tool arguments instead of repeating the same malformed call."
641 }
642 RuntimeFailureClass::ToolPolicyBlocked => {
643 "Stay inside the allowed workflow or switch modes before retrying."
644 }
645 RuntimeFailureClass::ToolLoop => {
646 "Stop repeating the same failing tool pattern and switch to a narrower recovery step."
647 }
648 RuntimeFailureClass::VerificationFailed => {
649 "Fix the build or test failure before treating the task as complete."
650 }
651 RuntimeFailureClass::EmptyModelResponse => {
652 "Retry once automatically, then narrow the turn or restart LM Studio if the model keeps returning nothing."
653 }
654 RuntimeFailureClass::Unknown => {
655 "Inspect the latest grounded tool results or provider status before retrying."
656 }
657 }
658 }
659}
660
661fn classify_runtime_failure(detail: &str) -> RuntimeFailureClass {
662 let lower = detail.to_ascii_lowercase();
663 if lower.contains("context_window_blocked")
664 || lower.contains("context ceiling reached")
665 || lower.contains("exceeds the")
666 || ((lower.contains("n_keep") && lower.contains("n_ctx"))
667 || lower.contains("context length")
668 || lower.contains("keep from the initial prompt")
669 || lower.contains("prompt is greater than the context length"))
670 {
671 RuntimeFailureClass::ContextWindow
672 } else if lower.contains("empty response from model")
673 || lower.contains("model returned an empty response")
674 {
675 RuntimeFailureClass::EmptyModelResponse
676 } else if lower.contains("lm studio unreachable")
677 || lower.contains("lm studio error")
678 || lower.contains("request failed")
679 || lower.contains("response parse error")
680 || lower.contains("provider degraded")
681 {
682 RuntimeFailureClass::ProviderDegraded
683 } else if lower.contains("missing required argument")
684 || lower.contains("json repair failed")
685 || lower.contains("invalid pattern")
686 || lower.contains("invalid line range")
687 {
688 RuntimeFailureClass::ToolArgMalformed
689 } else if lower.contains("action blocked:")
690 || lower.contains("access denied")
691 || lower.contains("declined by user")
692 {
693 RuntimeFailureClass::ToolPolicyBlocked
694 } else if lower.contains("too many consecutive tool errors")
695 || lower.contains("repeated tool failures")
696 || lower.contains("stuck in a loop")
697 {
698 RuntimeFailureClass::ToolLoop
699 } else if lower.contains("build failed")
700 || lower.contains("verification failed")
701 || lower.contains("verify_build")
702 {
703 RuntimeFailureClass::VerificationFailed
704 } else {
705 RuntimeFailureClass::Unknown
706 }
707}
708
709fn format_runtime_failure(class: RuntimeFailureClass, detail: &str) -> String {
710 format!(
711 "[failure:{}] {} Detail: {}",
712 class.tag(),
713 class.operator_guidance(),
714 detail.trim()
715 )
716}
717
718fn provider_state_for_runtime_failure(class: RuntimeFailureClass) -> Option<ProviderRuntimeState> {
719 match class {
720 RuntimeFailureClass::ContextWindow => Some(ProviderRuntimeState::ContextWindow),
721 RuntimeFailureClass::ProviderDegraded => Some(ProviderRuntimeState::Degraded),
722 RuntimeFailureClass::EmptyModelResponse => Some(ProviderRuntimeState::EmptyResponse),
723 _ => None,
724 }
725}
726
727fn checkpoint_state_for_runtime_failure(
728 class: RuntimeFailureClass,
729) -> Option<OperatorCheckpointState> {
730 match class {
731 RuntimeFailureClass::ContextWindow => Some(OperatorCheckpointState::BlockedContextWindow),
732 RuntimeFailureClass::ToolPolicyBlocked => Some(OperatorCheckpointState::BlockedPolicy),
733 RuntimeFailureClass::ToolLoop => Some(OperatorCheckpointState::BlockedToolLoop),
734 RuntimeFailureClass::VerificationFailed => {
735 Some(OperatorCheckpointState::BlockedVerification)
736 }
737 _ => None,
738 }
739}
740
741fn compact_runtime_recovery_summary(class: RuntimeFailureClass) -> &'static str {
742 match class {
743 RuntimeFailureClass::ProviderDegraded => {
744 "LM Studio degraded during the turn; retrying once before surfacing a failure."
745 }
746 RuntimeFailureClass::EmptyModelResponse => {
747 "The model returned an empty reply; retrying once before surfacing a failure."
748 }
749 _ => "Runtime recovery in progress.",
750 }
751}
752
753fn checkpoint_summary_for_runtime_failure(class: RuntimeFailureClass) -> &'static str {
754 match class {
755 RuntimeFailureClass::ContextWindow => "Provider context ceiling confirmed.",
756 RuntimeFailureClass::ToolPolicyBlocked => "Policy blocked the current action.",
757 RuntimeFailureClass::ToolLoop => "Repeated failing tool pattern stopped.",
758 RuntimeFailureClass::VerificationFailed => "Verification failed; fix before continuing.",
759 _ => "Operator checkpoint updated.",
760 }
761}
762
763fn compact_runtime_failure_summary(class: RuntimeFailureClass) -> &'static str {
764 match class {
765 RuntimeFailureClass::ContextWindow => "LM context ceiling hit.",
766 RuntimeFailureClass::ProviderDegraded => {
767 "LM Studio degraded and did not recover cleanly; operator action is now required."
768 }
769 RuntimeFailureClass::EmptyModelResponse => {
770 "LM Studio returned an empty reply after recovery; operator action is now required."
771 }
772 RuntimeFailureClass::ToolLoop => {
773 "Repeated failing tool pattern detected; Hematite stopped the loop."
774 }
775 _ => "Runtime failure surfaced to the operator.",
776 }
777}
778
779fn should_retry_runtime_failure(class: RuntimeFailureClass) -> bool {
780 matches!(
781 class,
782 RuntimeFailureClass::ProviderDegraded | RuntimeFailureClass::EmptyModelResponse
783 )
784}
785
786fn recovery_scenario_for_runtime_failure(class: RuntimeFailureClass) -> Option<RecoveryScenario> {
787 match class {
788 RuntimeFailureClass::ContextWindow => Some(RecoveryScenario::ContextWindow),
789 RuntimeFailureClass::ProviderDegraded => Some(RecoveryScenario::ProviderDegraded),
790 RuntimeFailureClass::EmptyModelResponse => Some(RecoveryScenario::EmptyModelResponse),
791 RuntimeFailureClass::ToolPolicyBlocked => Some(RecoveryScenario::McpWorkspaceReadBlocked),
792 RuntimeFailureClass::ToolLoop => Some(RecoveryScenario::ToolLoop),
793 RuntimeFailureClass::VerificationFailed => Some(RecoveryScenario::VerificationFailed),
794 RuntimeFailureClass::ToolArgMalformed | RuntimeFailureClass::Unknown => None,
795 }
796}
797
798fn compact_recovery_plan_summary(plan: &RecoveryPlan) -> String {
799 format!(
800 "{} [{}]",
801 plan.recipe.scenario.label(),
802 plan.recipe.steps_summary()
803 )
804}
805
806fn compact_recovery_decision_summary(decision: &RecoveryDecision) -> String {
807 match decision {
808 RecoveryDecision::Attempt(plan) => compact_recovery_plan_summary(plan),
809 RecoveryDecision::Escalate {
810 recipe,
811 attempts_made,
812 ..
813 } => format!(
814 "{} escalated after {} / {} [{}]",
815 recipe.scenario.label(),
816 attempts_made,
817 recipe.max_attempts.max(1),
818 recipe.steps_summary()
819 ),
820 }
821}
822
823fn parse_failing_paths_from_build_output(output: &str) -> Vec<String> {
826 let root = crate::tools::file_ops::workspace_root();
827 let mut paths: Vec<String> = output
828 .lines()
829 .filter_map(|line| {
830 let trimmed = line.trim_start();
831 let after_arrow = trimmed.strip_prefix("--> ")?;
833 let file_part = after_arrow.split(':').next()?;
834 if file_part.is_empty() || file_part.starts_with('<') {
835 return None;
836 }
837 let p = std::path::Path::new(file_part);
838 let resolved = if p.is_absolute() {
839 p.to_path_buf()
840 } else {
841 root.join(p)
842 };
843 Some(resolved.to_string_lossy().replace('\\', "/").to_lowercase())
844 })
845 .collect();
846 paths.sort();
847 paths.dedup();
848 paths
849}
850
851fn build_mode_redirect_answer(mode: WorkflowMode) -> String {
852 match mode {
853 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(),
854 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(),
855 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(),
856 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(),
857 _ => "Switch to `/code` or `/auto` to allow implementation.".to_string(),
858 }
859}
860
861fn architect_handoff_contract() -> &'static str {
862 "ARCHITECT OUTPUT CONTRACT:\n\
863Use a compact implementation handoff, not a process narrative.\n\
864Do not say \"the first step\" or describe what you are about to do.\n\
865After one or two read-only inspection tools at most, stop and answer.\n\
866For runtime wiring, reset behavior, or control-flow questions, prefer `trace_runtime_flow`.\n\
867Use these exact ASCII headings and keep each section short:\n\
868# Goal\n\
869# Target Files\n\
870# Ordered Steps\n\
871# Verification\n\
872# Risks\n\
873# Open Questions\n\
874Keep the whole handoff concise and implementation-oriented."
875}
876
877fn implement_current_plan_prompt() -> &'static str {
878 "Implement the current plan."
879}
880
881fn scaffold_protocol() -> &'static str {
882 "\n\n# SCAFFOLD MODE — PROJECT CREATION PROTOCOL\n\
883 The user wants a new project created. Your job is to build it completely, right now, without stopping.\n\
884 \n\
885 ## Autonomy rules\n\
886 - Build every file the project needs in one pass. Do NOT stop after one file and wait.\n\
887 - After writing each file, read it back to verify it is complete and not truncated.\n\
888 - Check cross-file consistency before finishing.\n\
889 - Once the project is coherent, runnable, and verified, STOP.\n\
890 - Mandatory Checklist Protocol: Whenever drafting a plan for a project scaffold, you MUST initialize a `.hematite/TASK.md` file with a granular `[ ]` checklist. Update it after every file mutation.\n\
891 - If only optional polish remains, present it as optional next steps instead of mutating more files.\n\
892 - Ask the user only when blocked by a real product decision, missing requirement, or risky/destructive choice.\n\
893 - Only surface results to the user once ALL files exist and the project is immediately runnable.\n\
894 - Final delivery must sound like a human engineer closeout: stack chosen, what was built, what was verified, and what remains optional.\n\
895 \n\
896 ## Infer the stack from context\n\
897 If the user gives only a vague request (\"make me a website\", \"build me a tool\"), pick the most\n\
898 sensible minimal stack and state your choice before creating files. Do not ask permission — choose and build.\n\
899 For scaffold/project-creation turns, do NOT use `run_workspace_workflow` unless the user explicitly asks you to run an existing build, test, lint, package script, or repo command.\n\
900 Default choices: website → static HTML+CSS+JS; CLI tool → Rust (clap) if Rust project, Python (argparse/click) otherwise;\n\
901 API → FastAPI (Python) or Express (Node); web app with state → React (Vite).\n\
902 \n\
903 ## Stack file structures\n\
904 \n\
905 **Static HTML site / landing page:**\n\
906 index.html (semantic: header/nav/main/footer, doctype, meta charset/viewport, linked CSS+JS),\n\
907 style.css (CSS variables, mobile-first, grid/flexbox, @media breakpoints, hover/focus states),\n\
908 script.js (DOMContentLoaded guard, smooth scroll, no console.log left in), README.md\n\
909 \n\
910 **React (Vite):**\n\
911 package.json (scripts: dev/build/preview, deps: react react-dom, devDeps: vite @vitejs/plugin-react),\n\
912 vite.config.js, index.html (root div), src/main.jsx, src/App.jsx, src/App.css, src/index.css, .gitignore, README.md\n\
913 \n\
914 **Next.js (App Router):**\n\
915 package.json (next react react-dom, scripts: dev/build/start),\n\
916 next.config.js, tsconfig.json, app/layout.tsx, app/page.tsx, app/globals.css, public/.gitkeep, .gitignore, README.md\n\
917 \n\
918 **Vue 3 (Vite):**\n\
919 package.json (vue, vite, @vitejs/plugin-vue),\n\
920 vite.config.js, index.html, src/main.js, src/App.vue, src/components/.gitkeep, .gitignore, README.md\n\
921 \n\
922 **SvelteKit:**\n\
923 package.json (@sveltejs/kit, svelte, vite, @sveltejs/adapter-auto),\n\
924 svelte.config.js, vite.config.js, src/routes/+page.svelte, src/app.html, static/.gitkeep, .gitignore, README.md\n\
925 \n\
926 **Express.js API:**\n\
927 package.json (express, cors, dotenv; nodemon as devDep; scripts: start/dev),\n\
928 src/index.js (listen + middleware), src/routes/index.js, src/middleware/error.js, .env.example, .gitignore, README.md\n\
929 \n\
930 **FastAPI (Python):**\n\
931 requirements.txt (fastapi, uvicorn[standard], pydantic),\n\
932 main.py (app = FastAPI(), include_router, uvicorn.run guard),\n\
933 app/__init__.py, app/routers/items.py, app/models.py, .gitignore (venv/ __pycache__/ .env), README.md\n\
934 \n\
935 **Flask (Python):**\n\
936 requirements.txt (flask, python-dotenv),\n\
937 app.py or app/__init__.py, app/routes.py, templates/base.html, static/style.css, .gitignore, README.md\n\
938 \n\
939 **Django:**\n\
940 requirements.txt, manage.py, project/settings.py, project/urls.py, project/wsgi.py,\n\
941 app/models.py, app/views.py, app/urls.py, templates/base.html, .gitignore, README.md\n\
942 \n\
943 **Python CLI (click or argparse):**\n\
944 pyproject.toml (name, version, [project.scripts] entry point) or setup.py,\n\
945 src/<name>/__init__.py, src/<name>/cli.py (click group or argparse main), src/<name>/core.py,\n\
946 README.md, .gitignore (__pycache__/ dist/ *.egg-info venv/)\n\
947 \n\
948 **Python package/library:**\n\
949 pyproject.toml (PEP 517/518, hatchling or setuptools), src/<name>/__init__.py, src/<name>/core.py,\n\
950 tests/__init__.py, tests/test_core.py, README.md, .gitignore\n\
951 \n\
952 **Rust CLI (clap):**\n\
953 Cargo.toml (name, edition=2021, clap with derive feature),\n\
954 src/main.rs (Cli struct with #[derive(Parser)], fn main), src/cli.rs (subcommands if needed),\n\
955 README.md, .gitignore (target/)\n\
956 \n\
957 **Rust library:**\n\
958 Cargo.toml ([lib], edition=2021), src/lib.rs (pub mod, pub fn, doc comments),\n\
959 tests/integration_test.rs, README.md, .gitignore\n\
960 \n\
961 **Go project / CLI:**\n\
962 go.mod (module <name>, go 1.21), main.go (package main, func main),\n\
963 cmd/<name>/main.go if CLI, internal/core/core.go for logic,\n\
964 README.md, .gitignore (bin/ *.exe)\n\
965 \n\
966 **C++ project (CMake):**\n\
967 CMakeLists.txt (cmake_minimum_required, project, add_executable, set C++17/20),\n\
968 src/main.cpp, include/<name>.h, src/<name>.cpp,\n\
969 README.md, .gitignore (build/ *.o *.exe CMakeCache.txt)\n\
970 \n\
971 **Node.js TypeScript API:**\n\
972 package.json (express @types/express typescript ts-node nodemon; scripts: build/dev/start),\n\
973 tsconfig.json (strict, esModuleInterop, outDir: dist), src/index.ts, src/routes/index.ts,\n\
974 .env.example, .gitignore, README.md\n\
975 \n\
976 ## File quality rules\n\
977 - Every file must be complete — no truncation, no placeholder comments like \"add logic here\"\n\
978 - package.json: name, version, scripts, all deps explicit\n\
979 - HTML: doctype, charset, viewport, title, all linked CSS/JS, semantic structure\n\
980 - CSS: consistent class names matching HTML exactly, responsive, variables for colors/spacing\n\
981 - .gitignore: cover node_modules/, dist/, .env, __pycache__/, target/, venv/, build/ as appropriate\n\
982 - Rust Cargo.toml: edition = \"2021\", all used crates declared\n\
983 - Go go.mod: module path and go version declared\n\
984 - C++ CMakeLists.txt: cmake version, project name, standard, all source files listed\n\
985 \n\
986 ## After scaffolding — required wrap-up\n\
987 1. List every file created with a one-line description of what it does\n\
988 2. Give the exact command(s) to install dependencies and run the project\n\
989 3. Tell the user they can type `/cd <project-folder>` to teleport into the new project\n\
990 4. Ask what they'd like to work on next — offer 2-3 specific suggestions relevant to the stack\n\
991 (e.g. \"Want me to add routing? Set up authentication? Add a dark mode toggle? Or should we improve the design?\")\n\
992 5. Stay engaged — you are their coding partner, not a one-shot file generator\n"
993}
994
995fn looks_like_static_site_request(input: &str) -> bool {
996 let lower = input.to_ascii_lowercase();
997 (lower.contains("website") || lower.contains("landing page") || lower.contains("web page"))
998 && (lower.contains("html")
999 || lower.contains("css")
1000 || lower.contains("javascript")
1001 || lower.contains("js")
1002 || !lower.contains("react"))
1003}
1004
1005fn sanitize_project_folder_name(raw: &str) -> String {
1006 let trimmed = raw
1007 .trim()
1008 .trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '.' | ',' | ':' | ';'));
1009 let mut out = String::new();
1010 for ch in trimmed.chars() {
1011 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ' ') {
1012 out.push(ch);
1013 } else {
1014 out.push('_');
1015 }
1016 }
1017 let cleaned = out.trim().replace(' ', "_");
1018 if cleaned.is_empty() {
1019 "hematite_project".to_string()
1020 } else {
1021 cleaned
1022 }
1023}
1024
1025fn extract_named_folder(lower: &str) -> Option<String> {
1026 for marker in [" named ", " called "] {
1027 if let Some(idx) = lower.find(marker) {
1028 let rest = &lower[idx + marker.len()..];
1029 let name = rest
1030 .split(|c: char| {
1031 c.is_whitespace() || matches!(c, ',' | '.' | ':' | ';' | '!' | '?')
1032 })
1033 .next()
1034 .unwrap_or("")
1035 .trim();
1036 if !name.is_empty() {
1037 return Some(sanitize_project_folder_name(name));
1038 }
1039 }
1040 }
1041 None
1042}
1043
1044fn extract_sovereign_scaffold_root(user_input: &str) -> Option<std::path::PathBuf> {
1045 let lower = user_input.to_ascii_lowercase();
1046 let folder_name = extract_named_folder(&lower)?;
1047
1048 let base = if lower.contains("desktop") {
1049 dirs::desktop_dir()
1050 } else if lower.contains("download") {
1051 dirs::download_dir()
1052 } else if lower.contains("document") || lower.contains("docs") {
1053 dirs::document_dir()
1054 } else {
1055 None
1056 }?;
1057
1058 Some(base.join(folder_name))
1059}
1060
1061fn default_sovereign_scaffold_targets(user_input: &str) -> std::collections::BTreeSet<String> {
1062 let mut targets = std::collections::BTreeSet::new();
1063 if looks_like_static_site_request(user_input) {
1064 targets.insert("index.html".to_string());
1065 targets.insert("style.css".to_string());
1066 targets.insert("script.js".to_string());
1067 }
1068 targets
1069}
1070
1071fn seed_sovereign_scaffold_files(
1072 root: &std::path::Path,
1073 targets: &std::collections::BTreeSet<String>,
1074) -> Result<(), String> {
1075 for relative in targets {
1076 let path = root.join(relative);
1077 if let Some(parent) = path.parent() {
1078 std::fs::create_dir_all(parent)
1079 .map_err(|e| format!("Failed to create scaffold parent directory: {e}"))?;
1080 }
1081 if !path.exists() {
1082 std::fs::write(&path, "")
1083 .map_err(|e| format!("Failed to seed scaffold file {}: {e}", path.display()))?;
1084 }
1085 }
1086 Ok(())
1087}
1088
1089fn write_sovereign_handoff_markdown(
1090 root: &std::path::Path,
1091 user_input: &str,
1092 plan: &crate::tools::plan::PlanHandoff,
1093) -> Result<(), String> {
1094 let handoff_path = root.join("HEMATITE_HANDOFF.md");
1095 let content = format!(
1096 "# Hematite Handoff\n\n\
1097 Original request:\n\
1098 - {}\n\n\
1099 This project root was pre-created by Hematite before teleport.\n\
1100 The next session should resume from the local `.hematite/PLAN.md` handoff and continue implementation here.\n\n\
1101 ## Planned Target Files\n{}\n\
1102 ## Verification\n- {}\n",
1103 user_input.trim(),
1104 if plan.target_files.is_empty() {
1105 "- project files to be created in the resumed session\n".to_string()
1106 } else {
1107 plan.target_files
1108 .iter()
1109 .map(|path| format!("- {path}\n"))
1110 .collect::<String>()
1111 },
1112 plan.verification.trim()
1113 );
1114 std::fs::write(&handoff_path, content)
1115 .map_err(|e| format!("Failed to write handoff markdown: {e}"))
1116}
1117
1118fn build_sovereign_scaffold_handoff(
1119 user_input: &str,
1120 target_files: &std::collections::BTreeSet<String>,
1121) -> crate::tools::plan::PlanHandoff {
1122 let mut steps = vec![
1123 "Read the scaffolded files in this root before changing them so the resumed session stays grounded in the actual generated content.".to_string(),
1124 "Finish the implementation inside this sovereign project root only; do not reason from the old workspace or unrelated ./src context.".to_string(),
1125 "Keep the file set coherent instead of thrashing cosmetics; once the project is runnable or internally consistent, stop and summarize like a human engineer.".to_string(),
1126 ];
1127 let verification = if looks_like_static_site_request(user_input) {
1128 steps.insert(
1129 1,
1130 "Make sure index.html, style.css, and script.js stay linked correctly and that the layout remains responsive on desktop and mobile.".to_string(),
1131 );
1132 "Open and inspect the generated front-end files in this root, confirm cross-file links are valid, and verify the page is coherent and responsive without using repo-root workflows.".to_string()
1133 } else {
1134 "Use only project-appropriate verification scoped to this root. Avoid unrelated repo workflows; verify the generated files are internally consistent before stopping.".to_string()
1135 };
1136
1137 crate::tools::plan::PlanHandoff {
1138 goal: format!(
1139 "Continue the sovereign scaffold task in this new project root: {}",
1140 user_input.trim()
1141 ),
1142 target_files: target_files.iter().cloned().collect(),
1143 ordered_steps: steps,
1144 verification,
1145 risks: vec![
1146 "Do not drift back into the originating workspace or unrelated ./src context."
1147 .to_string(),
1148 "Avoid endless UI polish loops once the generated project is already coherent."
1149 .to_string(),
1150 ],
1151 open_questions: Vec::new(),
1152 }
1153}
1154
1155fn architect_handoff_operator_note(plan: &crate::tools::plan::PlanHandoff) -> String {
1156 format!(
1157 "Implementation handoff saved to `.hematite/PLAN.md`.\nNext step: run `/implement-plan` to execute it in `/code`, or use `/code {}` directly.\nPlan: {}",
1158 implement_current_plan_prompt().to_ascii_lowercase(),
1159 plan.summary_line()
1160 )
1161}
1162
1163fn is_current_plan_execution_request(user_input: &str) -> bool {
1164 let lower = user_input.trim().to_ascii_lowercase();
1165 lower == "/implement-plan"
1166 || lower == implement_current_plan_prompt().to_ascii_lowercase()
1167 || lower
1168 == implement_current_plan_prompt()
1169 .trim_end_matches('.')
1170 .to_ascii_lowercase()
1171 || lower.contains("implement the current plan")
1172}
1173
1174fn is_plan_scoped_tool(name: &str) -> bool {
1175 crate::agent::inference::tool_metadata_for_name(name).plan_scope
1176}
1177
1178fn is_current_plan_irrelevant_tool(name: &str) -> bool {
1179 !crate::agent::inference::tool_metadata_for_name(name).plan_scope
1180}
1181
1182fn is_non_mutating_plan_step_tool(name: &str) -> bool {
1183 let metadata = crate::agent::inference::tool_metadata_for_name(name);
1184 metadata.plan_scope && !metadata.mutates_workspace
1185}
1186
1187fn parse_inline_workflow_prompt(user_input: &str) -> Option<(WorkflowMode, &str)> {
1188 let trimmed = user_input.trim();
1189 for (prefix, mode) in [
1190 ("/ask", WorkflowMode::Ask),
1191 ("/code", WorkflowMode::Code),
1192 ("/architect", WorkflowMode::Architect),
1193 ("/read-only", WorkflowMode::ReadOnly),
1194 ("/auto", WorkflowMode::Auto),
1195 ("/teach", WorkflowMode::Teach),
1196 ] {
1197 if let Some(rest) = trimmed.strip_prefix(prefix) {
1198 let rest = rest.trim();
1199 if !rest.is_empty() {
1200 return Some((mode, rest));
1201 }
1202 }
1203 }
1204 None
1205}
1206
1207pub fn get_tools() -> Vec<ToolDefinition> {
1211 crate::agent::tool_registry::get_tools()
1212}
1213
1214fn is_natural_language_hallucination(input: &str) -> bool {
1215 let lower = input.to_lowercase();
1216 let words = lower.split_whitespace().collect::<Vec<_>>();
1217
1218 if words.is_empty() {
1220 return false;
1221 }
1222 let first = words[0];
1223 if [
1224 "make", "create", "i", "can", "please", "we", "let's", "go", "execute", "run", "how",
1225 ]
1226 .contains(&first)
1227 {
1228 if words.len() >= 3 {
1230 return true;
1231 }
1232 }
1233
1234 let stop_words = [
1236 "the", "a", "an", "on", "my", "your", "for", "with", "into", "onto",
1237 ];
1238 let stop_count = words.iter().filter(|w| stop_words.contains(w)).count();
1239 if stop_count >= 2 {
1240 return true;
1241 }
1242
1243 if words.len() >= 5
1245 && !input.contains('-')
1246 && !input.contains('/')
1247 && !input.contains('\\')
1248 && !input.contains('.')
1249 {
1250 return true;
1251 }
1252
1253 false
1254}
1255
1256pub struct ConversationManager {
1257 pub history: Vec<ChatMessage>,
1259 pub engine: Arc<InferenceEngine>,
1260 pub tools: Vec<ToolDefinition>,
1261 pub mcp_manager: Arc<Mutex<crate::agent::mcp_manager::McpManager>>,
1262 pub professional: bool,
1263 pub brief: bool,
1264 pub snark: u8,
1265 pub chaos: u8,
1266 pub fast_model: Option<String>,
1268 pub think_model: Option<String>,
1270 pub correction_hints: Vec<String>,
1272 pub running_summary: Option<String>,
1274 pub gpu_state: Arc<GpuState>,
1276 pub vein: crate::memory::vein::Vein,
1278 pub transcript: crate::agent::transcript::TranscriptLogger,
1280 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
1282 pub git_state: Arc<crate::agent::git_monitor::GitState>,
1284 pub think_mode: Option<bool>,
1287 workflow_mode: WorkflowMode,
1288 pub session_memory: crate::agent::compaction::SessionMemory,
1290 pub swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1291 pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
1292 pub soul_personality: String,
1294 pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
1295 pub reasoning_history: Option<String>,
1297 pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
1299 action_grounding: Arc<Mutex<ActionGroundingState>>,
1301 plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
1303 plan_execution_pass_depth: Arc<std::sync::atomic::AtomicUsize>,
1305 recovery_context: RecoveryContext,
1307 pub l1_context: Option<String>,
1310 pub repo_map: Option<String>,
1312 pub turn_count: u32,
1314 pub last_goal: Option<String>,
1316 pub latest_target_dir: Option<String>,
1318 pending_teleport_handoff: Option<SovereignTeleportHandoff>,
1320}
1321
1322impl ConversationManager {
1323 fn vein_docs_only_mode(&self) -> bool {
1324 !crate::tools::file_ops::is_project_workspace()
1325 }
1326
1327 fn refresh_vein_index(&mut self) -> usize {
1328 let count = if self.vein_docs_only_mode() {
1329 tokio::task::block_in_place(|| {
1330 self.vein
1331 .index_workspace_artifacts(&crate::tools::file_ops::hematite_dir())
1332 })
1333 } else {
1334 tokio::task::block_in_place(|| self.vein.index_project())
1335 };
1336 self.l1_context = self.vein.l1_context();
1337 count
1338 }
1339
1340 fn build_vein_inspection_report(&self, indexed_this_pass: usize) -> String {
1341 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(8));
1342 let workspace_mode = if self.vein_docs_only_mode() {
1343 "docs-only (outside a project workspace)"
1344 } else {
1345 "project workspace"
1346 };
1347 let active_room = snapshot.active_room.as_deref().unwrap_or("none");
1348 let mut out = format!(
1349 "Vein Inspection\n\
1350 Workspace mode: {workspace_mode}\n\
1351 Indexed this pass: {indexed_this_pass}\n\
1352 Indexed source files: {}\n\
1353 Indexed docs: {}\n\
1354 Indexed session exchanges: {}\n\
1355 Embedded source/doc chunks: {}\n\
1356 Embeddings available: {}\n\
1357 Active room bias: {active_room}\n\
1358 L1 hot-files block: {}\n",
1359 snapshot.indexed_source_files,
1360 snapshot.indexed_docs,
1361 snapshot.indexed_session_exchanges,
1362 snapshot.embedded_source_doc_chunks,
1363 if snapshot.has_any_embeddings {
1364 "yes"
1365 } else {
1366 "no"
1367 },
1368 if snapshot.l1_ready {
1369 "ready"
1370 } else {
1371 "not built yet"
1372 },
1373 );
1374
1375 if snapshot.hot_files.is_empty() {
1376 out.push_str("Hot files: none yet.\n");
1377 return out;
1378 }
1379
1380 out.push_str("\nHot files by room:\n");
1381 let mut by_room: std::collections::BTreeMap<&str, Vec<&crate::memory::vein::VeinHotFile>> =
1382 std::collections::BTreeMap::new();
1383 for file in &snapshot.hot_files {
1384 by_room.entry(file.room.as_str()).or_default().push(file);
1385 }
1386 for (room, files) in by_room {
1387 out.push_str(&format!("[{}]\n", room));
1388 for file in files {
1389 out.push_str(&format!(
1390 "- {} [{} edit{}]\n",
1391 file.path,
1392 file.heat,
1393 if file.heat == 1 { "" } else { "s" }
1394 ));
1395 }
1396 }
1397
1398 out
1399 }
1400
1401 fn latest_user_prompt(&self) -> Option<&str> {
1402 self.history
1403 .iter()
1404 .rev()
1405 .find(|msg| msg.role == "user")
1406 .map(|msg| msg.content.as_str())
1407 }
1408
1409 async fn emit_direct_response(
1410 &mut self,
1411 tx: &mpsc::Sender<InferenceEvent>,
1412 raw_user_input: &str,
1413 effective_user_input: &str,
1414 response: &str,
1415 ) {
1416 self.history.push(ChatMessage::user(effective_user_input));
1417 self.history.push(ChatMessage::assistant_text(response));
1418 self.transcript.log_user(raw_user_input);
1419 self.transcript.log_agent(response);
1420 for chunk in chunk_text(response, 8) {
1421 if !chunk.is_empty() {
1422 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1423 }
1424 }
1425 if let Some(path) = self.latest_target_dir.take() {
1426 self.persist_pending_teleport_handoff();
1427 let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1428 }
1429 let _ = tx.send(InferenceEvent::Done).await;
1430 self.trim_history(80);
1431 self.refresh_session_memory();
1432 self.save_session();
1433 }
1434
1435 async fn emit_operator_checkpoint(
1436 &mut self,
1437 tx: &mpsc::Sender<InferenceEvent>,
1438 state: OperatorCheckpointState,
1439 summary: impl Into<String>,
1440 ) {
1441 let summary = summary.into();
1442 self.session_memory
1443 .record_checkpoint(state.label(), summary.clone());
1444 let _ = tx
1445 .send(InferenceEvent::OperatorCheckpoint { state, summary })
1446 .await;
1447 }
1448
1449 async fn emit_recovery_recipe_summary(
1450 &mut self,
1451 tx: &mpsc::Sender<InferenceEvent>,
1452 state: impl Into<String>,
1453 summary: impl Into<String>,
1454 ) {
1455 let state = state.into();
1456 let summary = summary.into();
1457 self.session_memory.record_recovery(state, summary.clone());
1458 let _ = tx.send(InferenceEvent::RecoveryRecipe { summary }).await;
1459 }
1460
1461 async fn emit_provider_live(&mut self, tx: &mpsc::Sender<InferenceEvent>) {
1462 let _ = tx
1463 .send(InferenceEvent::ProviderStatus {
1464 state: ProviderRuntimeState::Live,
1465 summary: String::new(),
1466 })
1467 .await;
1468 self.emit_operator_checkpoint(tx, OperatorCheckpointState::Idle, "")
1469 .await;
1470 }
1471
1472 async fn emit_prompt_pressure_for_messages(
1473 &self,
1474 tx: &mpsc::Sender<InferenceEvent>,
1475 messages: &[ChatMessage],
1476 ) {
1477 let context_length = self.engine.current_context_length();
1478 let (estimated_input_tokens, reserved_output_tokens, estimated_total_tokens, percent) =
1479 crate::agent::inference::estimate_prompt_pressure(
1480 messages,
1481 &self.tools,
1482 context_length,
1483 );
1484 let _ = tx
1485 .send(InferenceEvent::PromptPressure {
1486 estimated_input_tokens,
1487 reserved_output_tokens,
1488 estimated_total_tokens,
1489 context_length,
1490 percent,
1491 })
1492 .await;
1493 }
1494
1495 async fn emit_prompt_pressure_idle(&self, tx: &mpsc::Sender<InferenceEvent>) {
1496 let context_length = self.engine.current_context_length();
1497 let _ = tx
1498 .send(InferenceEvent::PromptPressure {
1499 estimated_input_tokens: 0,
1500 reserved_output_tokens: 0,
1501 estimated_total_tokens: 0,
1502 context_length,
1503 percent: 0,
1504 })
1505 .await;
1506 }
1507
1508 async fn emit_compaction_pressure(&self, tx: &mpsc::Sender<InferenceEvent>) {
1509 let context_length = self.engine.current_context_length();
1510 let vram_ratio = self.gpu_state.ratio();
1511 let config = CompactionConfig::adaptive(context_length, vram_ratio);
1512 let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
1513 let percent = if config.max_estimated_tokens == 0 {
1514 0
1515 } else {
1516 ((estimated_tokens.saturating_mul(100)) / config.max_estimated_tokens).min(100) as u8
1517 };
1518
1519 let _ = tx
1520 .send(InferenceEvent::CompactionPressure {
1521 estimated_tokens,
1522 threshold_tokens: config.max_estimated_tokens,
1523 percent,
1524 })
1525 .await;
1526 }
1527
1528 async fn refresh_runtime_profile_and_report(
1529 &mut self,
1530 tx: &mpsc::Sender<InferenceEvent>,
1531 reason: &str,
1532 ) -> Option<(String, usize, bool)> {
1533 let refreshed = self.engine.refresh_runtime_profile().await;
1534 if let Some((model_id, context_length, changed)) = refreshed.as_ref() {
1535 let _ = tx
1536 .send(InferenceEvent::RuntimeProfile {
1537 model_id: model_id.clone(),
1538 context_length: *context_length,
1539 })
1540 .await;
1541 self.transcript.log_system(&format!(
1542 "Runtime profile refresh ({}): model={} ctx={} changed={}",
1543 reason, model_id, context_length, changed
1544 ));
1545 }
1546 refreshed
1547 }
1548
1549 pub fn new(
1550 engine: Arc<InferenceEngine>,
1551 professional: bool,
1552 brief: bool,
1553 snark: u8,
1554 chaos: u8,
1555 soul_personality: String,
1556 fast_model: Option<String>,
1557 think_model: Option<String>,
1558 gpu_state: Arc<GpuState>,
1559 git_state: Arc<crate::agent::git_monitor::GitState>,
1560 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1561 voice_manager: Arc<crate::ui::voice::VoiceManager>,
1562 ) -> Self {
1563 let (saved_summary, saved_memory) = load_session_data();
1564
1565 let mcp_manager = Arc::new(tokio::sync::Mutex::new(
1567 crate::agent::mcp_manager::McpManager::new(),
1568 ));
1569
1570 let dynamic_instructions =
1572 engine.build_system_prompt(snark, chaos, brief, professional, &[], None, &[]);
1573
1574 let history = vec![ChatMessage::system(&dynamic_instructions)];
1575
1576 let vein_path = crate::tools::file_ops::hematite_dir().join("vein.db");
1577 let vein_base_url = engine.base_url.clone();
1578 let vein = crate::memory::vein::Vein::new(&vein_path, vein_base_url.clone())
1579 .unwrap_or_else(|_| crate::memory::vein::Vein::new(":memory:", vein_base_url).unwrap());
1580
1581 Self {
1582 history,
1583 engine,
1584 tools: get_tools(),
1585 mcp_manager,
1586 professional,
1587 brief,
1588 snark,
1589 chaos,
1590 fast_model,
1591 think_model,
1592 correction_hints: Vec::new(),
1593 running_summary: saved_summary,
1594 gpu_state,
1595 vein,
1596 transcript: crate::agent::transcript::TranscriptLogger::new(),
1597 cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1598 git_state,
1599 think_mode: None,
1600 workflow_mode: WorkflowMode::Auto,
1601 session_memory: saved_memory,
1602 swarm_coordinator,
1603 voice_manager,
1604 soul_personality,
1605 lsp_manager: Arc::new(Mutex::new(crate::agent::lsp::manager::LspManager::new(
1606 crate::tools::file_ops::workspace_root(),
1607 ))),
1608 reasoning_history: None,
1609 pinned_files: Arc::new(Mutex::new(std::collections::HashMap::new())),
1610 action_grounding: Arc::new(Mutex::new(ActionGroundingState::default())),
1611 plan_execution_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1612 plan_execution_pass_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1613 recovery_context: RecoveryContext::default(),
1614 l1_context: None,
1615 repo_map: None,
1616 turn_count: 0,
1617 last_goal: None,
1618 latest_target_dir: None,
1619 pending_teleport_handoff: None,
1620 }
1621 }
1622
1623 async fn emit_done_events(&mut self, tx: &tokio::sync::mpsc::Sender<InferenceEvent>) {
1624 if let Some(path) = self.latest_target_dir.take() {
1625 self.persist_pending_teleport_handoff();
1626 let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1627 }
1628 let _ = tx.send(InferenceEvent::Done).await;
1629 }
1630
1631 pub fn initialize_vein(&mut self) -> usize {
1634 self.refresh_vein_index()
1635 }
1636
1637 pub fn initialize_repo_map(&mut self) {
1639 if !self.vein_docs_only_mode() {
1640 let root = crate::tools::file_ops::workspace_root();
1641 let hot = self.vein.hot_files_weighted(10);
1642 let gen = crate::memory::repo_map::RepoMapGenerator::new(&root).with_hot_files(&hot);
1643 match tokio::task::block_in_place(|| gen.generate()) {
1644 Ok(map) => self.repo_map = Some(map),
1645 Err(e) => {
1646 self.repo_map = Some(format!("Repo Map generation failed: {}", e));
1647 }
1648 }
1649 }
1650 }
1651
1652 fn refresh_repo_map(&mut self) {
1655 self.initialize_repo_map();
1656 }
1657
1658 fn save_session(&self) {
1659 let path = session_path();
1660 if let Some(parent) = path.parent() {
1661 let _ = std::fs::create_dir_all(parent);
1662 }
1663 let saved = SavedSession {
1664 running_summary: self.running_summary.clone(),
1665 session_memory: self.session_memory.clone(),
1666 last_goal: self.last_goal.clone(),
1667 turn_count: self.turn_count,
1668 };
1669 if let Ok(json) = serde_json::to_string(&saved) {
1670 let _ = std::fs::write(&path, json);
1671 }
1672 }
1673
1674 fn save_empty_session(&self) {
1675 let path = session_path();
1676 if let Some(parent) = path.parent() {
1677 let _ = std::fs::create_dir_all(parent);
1678 }
1679 let saved = SavedSession {
1680 running_summary: None,
1681 session_memory: crate::agent::compaction::SessionMemory::default(),
1682 last_goal: None,
1683 turn_count: 0,
1684 };
1685 if let Ok(json) = serde_json::to_string(&saved) {
1686 let _ = std::fs::write(&path, json);
1687 }
1688 }
1689
1690 fn refresh_session_memory(&mut self) {
1691 let current_plan = self.session_memory.current_plan.clone();
1692 let previous_memory = self.session_memory.clone();
1693 self.session_memory = compaction::extract_memory(&self.history);
1694 self.session_memory.current_plan = current_plan;
1695 self.session_memory
1696 .inherit_runtime_ledger_from(&previous_memory);
1697 }
1698
1699 fn build_chat_system_prompt(&self) -> String {
1700 let species = &self.engine.species;
1701 let personality = &self.soul_personality;
1702 format!(
1703 "You are {species}, a local AI companion running entirely on the user's GPU — no cloud, no subscriptions, no phoning home.\n\
1704 {personality}\n\n\
1705 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\
1706 Rules:\n\
1707 - Talk like a person. Skip the bullet-point breakdowns unless the topic genuinely needs structure.\n\
1708 - Answer directly. One paragraph is usually right.\n\
1709 - Don't call tools unless the user explicitly asks you to look at a file or run something.\n\
1710 - Don't narrate your reasoning or mention tool names unprompted.\n\
1711 - You can discuss code, debug ideas, explain concepts, help plan, or just talk.\n\
1712 - If the user clearly wants you to edit or build something, do it — but lead with conversation, not scaffolding.\n\
1713 - If the user wants the full coding harness, they can type `/agent`.\n",
1714 )
1715 }
1716
1717 fn append_session_handoff(&self, system_msg: &mut String) {
1718 let has_summary = self
1719 .running_summary
1720 .as_ref()
1721 .map(|s| !s.trim().is_empty())
1722 .unwrap_or(false);
1723 let has_memory = self.session_memory.has_signal();
1724
1725 if !has_summary && !has_memory {
1726 return;
1727 }
1728
1729 system_msg.push_str(
1730 "\n\n# LIGHTWEIGHT SESSION HANDOFF\n\
1731 This is compact carry-over from earlier work on this machine.\n\
1732 Use it only when it helps the current request.\n\
1733 Prefer current repository state, pinned files, and fresh tool results over stale session memory.\n",
1734 );
1735
1736 if has_memory {
1737 system_msg.push_str("\n## Active Task Memory\n");
1738 system_msg.push_str(&self.session_memory.to_prompt());
1739 }
1740
1741 if let Some(summary) = self.running_summary.as_deref() {
1742 if !summary.trim().is_empty() {
1743 system_msg.push_str("\n## Compacted Session Summary\n");
1744 system_msg.push_str(summary);
1745 system_msg.push('\n');
1746 }
1747 }
1748 }
1749
1750 fn set_workflow_mode(&mut self, mode: WorkflowMode) {
1751 self.workflow_mode = mode;
1752 }
1753
1754 fn current_plan_summary(&self) -> Option<String> {
1755 self.session_memory
1756 .current_plan
1757 .as_ref()
1758 .filter(|plan| plan.has_signal())
1759 .map(|plan| plan.summary_line())
1760 }
1761
1762 fn current_plan_allowed_paths(&self) -> Vec<String> {
1763 self.session_memory
1764 .current_plan
1765 .as_ref()
1766 .map(|plan| merge_plan_allowed_paths(&plan.target_files))
1767 .unwrap_or_default()
1768 }
1769
1770 fn current_plan_root_paths(&self) -> Vec<String> {
1771 use std::collections::BTreeSet;
1772
1773 let mut roots = BTreeSet::new();
1774 for path in self.current_plan_allowed_paths() {
1775 if let Some(parent) = std::path::Path::new(&path).parent() {
1776 roots.insert(parent.to_string_lossy().replace('\\', "/").to_lowercase());
1777 }
1778 }
1779 roots.into_iter().collect()
1780 }
1781
1782 fn persist_architect_handoff(
1783 &mut self,
1784 response: &str,
1785 ) -> Option<crate::tools::plan::PlanHandoff> {
1786 if self.workflow_mode != WorkflowMode::Architect {
1787 return None;
1788 }
1789 let Some(plan) = crate::tools::plan::parse_plan_handoff(response) else {
1790 return None;
1791 };
1792 let _ = crate::tools::plan::save_plan_handoff(&plan);
1793 self.session_memory.current_plan = Some(plan.clone());
1794 Some(plan)
1795 }
1796
1797 fn persist_pending_teleport_handoff(&mut self) {
1798 let Some(handoff) = self.pending_teleport_handoff.take() else {
1799 return;
1800 };
1801 let root = std::path::PathBuf::from(&handoff.root);
1802 let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &handoff.plan);
1803 let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
1804 }
1805
1806 async fn begin_grounded_turn(&self) -> u64 {
1807 let mut state = self.action_grounding.lock().await;
1808 state.turn_index += 1;
1809 state.turn_index
1810 }
1811
1812 async fn reset_action_grounding(&self) {
1813 let mut state = self.action_grounding.lock().await;
1814 *state = ActionGroundingState::default();
1815 }
1816
1817 async fn register_at_file_mentions(&self, input: &str) {
1821 if !input.contains('@') {
1822 return;
1823 }
1824 let cwd = match std::env::current_dir() {
1825 Ok(d) => d,
1826 Err(_) => return,
1827 };
1828 let mut state = self.action_grounding.lock().await;
1829 let turn = state.turn_index;
1830 for token in input.split_whitespace() {
1831 if !token.starts_with('@') {
1832 continue;
1833 }
1834 let raw = token
1835 .trim_start_matches('@')
1836 .trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
1837 if raw.is_empty() {
1838 continue;
1839 }
1840 if cwd.join(raw).is_file() {
1841 let normalized = normalize_workspace_path(raw);
1842 state.observed_paths.insert(normalized.clone(), turn);
1843 state.inspected_paths.insert(normalized, turn);
1844 }
1845 }
1846 }
1847
1848 async fn record_read_observation(&self, path: &str) {
1849 let normalized = normalize_workspace_path(path);
1850 let mut state = self.action_grounding.lock().await;
1851 let turn = state.turn_index;
1852 state.observed_paths.insert(normalized.clone(), turn);
1856 state.inspected_paths.insert(normalized, turn);
1857 }
1858
1859 async fn record_line_inspection(&self, path: &str) {
1860 let normalized = normalize_workspace_path(path);
1861 let mut state = self.action_grounding.lock().await;
1862 let turn = state.turn_index;
1863 state.observed_paths.insert(normalized.clone(), turn);
1864 state.inspected_paths.insert(normalized, turn);
1865 }
1866
1867 async fn record_verify_build_result(&self, ok: bool, output: &str) {
1868 let mut state = self.action_grounding.lock().await;
1869 let turn = state.turn_index;
1870 state.last_verify_build_turn = Some(turn);
1871 state.last_verify_build_ok = ok;
1872 if ok {
1873 state.code_changed_since_verify = false;
1874 state.last_failed_build_paths.clear();
1875 } else {
1876 state.last_failed_build_paths = parse_failing_paths_from_build_output(output);
1877 }
1878 }
1879
1880 fn record_session_verification(&mut self, ok: bool, summary: impl Into<String>) {
1881 self.session_memory.record_verification(ok, summary);
1882 }
1883
1884 async fn record_successful_mutation(&self, path: Option<&str>) {
1885 let mut state = self.action_grounding.lock().await;
1886 state.code_changed_since_verify = match path {
1887 Some(p) => is_code_like_path(p),
1888 None => true,
1889 };
1890 }
1891
1892 async fn validate_action_preconditions(&self, name: &str, args: &Value) -> Result<(), String> {
1893 if self
1894 .plan_execution_active
1895 .load(std::sync::atomic::Ordering::SeqCst)
1896 {
1897 if is_current_plan_irrelevant_tool(name) {
1898 let prompt = self.latest_user_prompt().unwrap_or("");
1899 let explicit_override = is_sovereign_path_request(prompt)
1900 || prompt.contains(name)
1901 || prompt.contains("/dev/null");
1902 if !explicit_override {
1903 return Err(format!(
1904 "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.",
1905 name
1906 ));
1907 }
1908 }
1909
1910 if is_plan_scoped_tool(name) {
1911 let allowed_paths = self.current_plan_allowed_paths();
1912 if !allowed_paths.is_empty() {
1913 let allowed_roots = self.current_plan_root_paths();
1914 let in_allowed = match name {
1915 "auto_pin_context" => args
1916 .get("paths")
1917 .and_then(|v| v.as_array())
1918 .map(|paths| {
1919 !paths.is_empty()
1920 && paths.iter().all(|v| {
1921 v.as_str()
1922 .map(normalize_workspace_path)
1923 .map(|p| allowed_paths.contains(&p))
1924 .unwrap_or(false)
1925 })
1926 })
1927 .unwrap_or(false),
1928 "grep_files" | "list_files" => {
1929 let raw_val = args.get("path").and_then(|v| v.as_str());
1930 let path_to_check = if let Some(p) = raw_val {
1931 let trimmed = p.trim();
1932 if trimmed.is_empty() || trimmed == "." || trimmed == "./" {
1933 ""
1934 } else {
1935 trimmed
1936 }
1937 } else {
1938 ""
1939 };
1940 if path_to_check.is_empty() {
1943 true
1944 } else {
1945 let p = normalize_workspace_path(path_to_check);
1946 allowed_paths.contains(&p)
1949 || allowed_roots.iter().any(|root| root == &p)
1950 || allowed_paths.iter().any(|ap| {
1951 ap.starts_with(&format!("{}/", p))
1952 || ap.starts_with(&format!("{}\\", p))
1953 })
1954 }
1955 }
1956 _ => {
1957 let target = action_target_path(name, args);
1958 let in_allowed = target
1959 .as_ref()
1960 .map(|p| allowed_paths.contains(p))
1961 .unwrap_or(false);
1962 let raw_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
1963 in_allowed || is_sovereign_path_request(raw_path)
1964 }
1965 };
1966
1967 if !in_allowed {
1968 let allowed = allowed_paths
1969 .iter()
1970 .map(|p| format!("`{}`", p))
1971 .collect::<Vec<_>>()
1972 .join(", ");
1973 return Err(format!(
1974 "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: {}.",
1975 allowed
1976 ));
1977 }
1978 }
1979 }
1980
1981 if matches!(name, "edit_file" | "multi_search_replace" | "patch_hunk") {
1982 if let Some(target) = action_target_path(name, args) {
1983 let state = self.action_grounding.lock().await;
1984 let recently_inspected = state
1985 .inspected_paths
1986 .get(&target)
1987 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1988 .unwrap_or(false);
1989 drop(state);
1990 if !recently_inspected {
1991 return Err(format!(
1992 "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.",
1993 name, target
1994 ));
1995 }
1996 }
1997 }
1998 }
1999
2000 if self.workflow_mode.is_read_only() && name == "auto_pin_context" {
2001 return Err(
2002 "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."
2003 .to_string(),
2004 );
2005 }
2006
2007 if self.workflow_mode.is_read_only() && is_destructive_tool(name) {
2008 if name == "shell" {
2009 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2010 let risk = crate::tools::guard::classify_bash_risk(command);
2011 if !matches!(risk, crate::tools::RiskLevel::Safe) {
2012 return Err(format!(
2013 "Action blocked: workflow mode `{}` is read-only for risky or mutating operations. Switch to `/code` or `/auto` before making changes.",
2014 self.workflow_mode.label()
2015 ));
2016 }
2017 } else {
2018 return Err(format!(
2019 "Action blocked: workflow mode `{}` is read-only. Use `/code` to implement changes or `/auto` to leave mode selection to Hematite.",
2020 self.workflow_mode.label()
2021 ));
2022 }
2023 }
2024
2025 let normalized_target = action_target_path(name, args);
2026 if let Some(target) = normalized_target.as_deref() {
2027 if matches!(
2028 name,
2029 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2030 ) {
2031 if let Some(prompt) = self.latest_user_prompt() {
2032 if docs_edit_without_explicit_request(prompt, target) {
2033 return Err(format!(
2034 "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.",
2035 target
2036 ));
2037 }
2038 }
2039 }
2040 let path_exists = std::path::Path::new(target).exists();
2041 if path_exists {
2042 let state = self.action_grounding.lock().await;
2043 let pinned = self.pinned_files.lock().await;
2044 let pinned_match = pinned.keys().any(|p| normalize_workspace_path(p) == target);
2045 drop(pinned);
2046
2047 let needs_exact_window = matches!(name, "edit_file" | "multi_search_replace");
2052 let recently_inspected = state
2053 .inspected_paths
2054 .get(target)
2055 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2056 .unwrap_or(false);
2057 let same_turn_read = state
2058 .observed_paths
2059 .get(target)
2060 .map(|turn| state.turn_index.saturating_sub(*turn) == 0)
2061 .unwrap_or(false);
2062 let recent_observed = state
2063 .observed_paths
2064 .get(target)
2065 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2066 .unwrap_or(false);
2067
2068 if matches!(
2069 name,
2070 "read_file" | "inspect_lines" | "list_files" | "grep_files"
2071 ) {
2072 } else if name == "write_file" && matches!(self.workflow_mode, WorkflowMode::Code) {
2075 let size = std::fs::metadata(target).map(|m| m.len()).unwrap_or(0);
2076 if size > 2000 {
2077 return Err(format!(
2079 "SURGICAL MANDATE: '{}' already exists and is significant ({} bytes). In implementation mode, you must use `edit_file` or `patch_hunk` for targeted changes instead of rewriting the entire file with `write_file`. This maintains project integrity and prevents context burn. HINT: Use `read_file` to capture the current state, then use `edit_file` with the exact text you want to replace in `target_content`.",
2080 target, size
2081 ));
2082 }
2083 } else if needs_exact_window {
2084 if !recently_inspected && !same_turn_read && !pinned_match {
2085 return Err(format!(
2086 "Action blocked: `{}` on '{}' requires a line-level inspection first. \
2087 Use `inspect_lines` on the target region to get the exact current text \
2088 (whitespace and indentation included), then retry the edit.",
2089 name, target
2090 ));
2091 }
2092 } else if !recent_observed && !pinned_match {
2093 return Err(format!(
2094 "Action blocked: `{}` on '{}' requires recent file evidence. Use `read_file` or `inspect_lines` on that path first, or pin the file into active context.",
2095 name, target
2096 ));
2097 }
2098 }
2099 }
2100
2101 if is_mcp_mutating_tool(name) {
2102 return Err(format!(
2103 "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.",
2104 name
2105 ));
2106 }
2107
2108 if is_mcp_workspace_read_tool(name) {
2109 return Err(format!(
2110 "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.",
2111 name
2112 ));
2113 }
2114
2115 if matches!(
2118 name,
2119 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2120 ) {
2121 if let Some(target) = normalized_target.as_deref() {
2122 let state = self.action_grounding.lock().await;
2123 if state.code_changed_since_verify
2124 && !state.last_verify_build_ok
2125 && !state.last_failed_build_paths.is_empty()
2126 && !state.last_failed_build_paths.iter().any(|p| p == target)
2127 {
2128 let files = state
2129 .last_failed_build_paths
2130 .iter()
2131 .map(|p| format!("`{}`", p))
2132 .collect::<Vec<_>>()
2133 .join(", ");
2134 return Err(format!(
2135 "Action blocked: the build is broken. Fix the errors in {} before editing other files. Re-run workspace verification to confirm the fix, then continue.",
2136 files
2137 ));
2138 }
2139 }
2140 }
2141
2142 if name == "git_commit" || name == "git_push" {
2143 let state = self.action_grounding.lock().await;
2144 if state.code_changed_since_verify && !state.last_verify_build_ok {
2145 return Err(format!(
2146 "Action blocked: `{}` requires a successful verification pass after the latest code edits. Run verification first so Hematite has proof that the workspace is clean.",
2147 name
2148 ));
2149 }
2150 }
2151
2152 if name == "shell" {
2153 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2154 if shell_looks_like_structured_host_inspection(command) {
2155 let topic = match preferred_host_inspection_topic(command) {
2160 Some(t) => t.to_string(),
2161 None => return Ok(()), };
2163
2164 {
2165 let mut state = self.action_grounding.lock().await;
2166 let current_turn = state.turn_index;
2167 if let Some(turn) = state.redirected_host_inspection_topics.get(&topic) {
2168 if *turn == current_turn {
2169 return Err(format!(
2170 "[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."
2171 ));
2172 }
2173 }
2174 state
2175 .redirected_host_inspection_topics
2176 .insert(topic.clone(), current_turn);
2177 }
2178
2179 let path_val = self
2180 .latest_user_prompt()
2181 .and_then(|p| {
2182 p.split_whitespace()
2184 .find(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
2185 .map(|s| {
2186 s.trim_matches(|c: char| {
2187 !c.is_alphanumeric() && c != '.' && c != '/' && c != '\\'
2188 })
2189 })
2190 })
2191 .unwrap_or("");
2192
2193 let mut redirect_args = if !path_val.is_empty() {
2194 serde_json::json!({ "topic": topic, "path": path_val })
2195 } else {
2196 serde_json::json!({ "topic": topic })
2197 };
2198
2199 if topic == "dns_lookup" {
2201 if let Some(identity) = extract_dns_lookup_target_from_shell(command) {
2202 redirect_args
2203 .as_object_mut()
2204 .unwrap()
2205 .insert("name".to_string(), serde_json::Value::String(identity));
2206 }
2207 if let Some(record_type) = extract_dns_record_type_from_shell(command) {
2208 redirect_args.as_object_mut().unwrap().insert(
2209 "type".to_string(),
2210 serde_json::Value::String(record_type.to_string()),
2211 );
2212 }
2213 } else if topic == "ad_user" {
2214 let cmd_lower = command.to_lowercase();
2215 let mut identity = String::new();
2216
2217 if let Some(idx) = cmd_lower.find("-identity") {
2219 let after_id = &command[idx + 9..].trim();
2220 identity = if after_id.starts_with('\'') || after_id.starts_with('"') {
2221 let quote = after_id.chars().next().unwrap();
2222 after_id.split(quote).nth(1).unwrap_or("").to_string()
2223 } else {
2224 after_id.split_whitespace().next().unwrap_or("").to_string()
2225 };
2226 }
2227
2228 if identity.is_empty() {
2230 let parts: Vec<&str> = command.split_whitespace().collect();
2231 for (i, part) in parts.iter().enumerate() {
2232 if i == 0 || part.starts_with('-') {
2233 continue;
2234 }
2235 let p_low = part.to_lowercase();
2237 if p_low.contains("get-ad")
2238 || p_low.contains("powershell")
2239 || p_low == "-command"
2240 {
2241 continue;
2242 }
2243
2244 identity = part
2245 .trim_matches(|c: char| c == '\'' || c == '"')
2246 .to_string();
2247 if !identity.is_empty() {
2248 break;
2249 }
2250 }
2251 }
2252
2253 if !identity.is_empty() {
2254 redirect_args.as_object_mut().unwrap().insert(
2255 "name_filter".to_string(),
2256 serde_json::Value::String(identity),
2257 );
2258 }
2259 }
2260
2261 let result = crate::tools::host_inspect::inspect_host(&redirect_args).await;
2262 return match result {
2263 Ok(output) => Err(format!(
2264 "[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.]"
2265 )),
2266 Err(e) => Err(format!(
2267 "Redirection to native tool `{topic}` failed: {e}\n\nAction blocked: use `inspect_host(topic: \"{topic}\")` instead of raw `shell` for host-inspection questions. Available topics: updates, security, pending_reboot, disk_health, battery, recent_crashes, scheduled_tasks, dev_conflicts, health_report, storage, hardware, resource_load, overclocker, processes, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, hyperv, event_query, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, network_profile, services, ports, env_doctor, fix_plan, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, docker, docker_filesystems, wsl, wsl_filesystems, ssh, env, hosts_file, installed_software, git_config, databases, disk_benchmark, directory, permissions, login_history, registry_audit, share_access.",
2268 )),
2269 };
2270 }
2271 let reason = args
2272 .get("reason")
2273 .and_then(|v| v.as_str())
2274 .unwrap_or("")
2275 .trim();
2276 let risk = crate::tools::guard::classify_bash_risk(command);
2277 if !matches!(risk, crate::tools::RiskLevel::Safe) && reason.is_empty() {
2278 return Err(
2279 "Action blocked: risky `shell` calls require a concrete `reason` argument that explains what is being verified or changed."
2280 .to_string(),
2281 );
2282 }
2283 }
2284
2285 Ok(())
2286 }
2287
2288 fn build_action_receipt(
2289 &self,
2290 name: &str,
2291 args: &Value,
2292 output: &str,
2293 is_error: bool,
2294 ) -> Option<ChatMessage> {
2295 if is_error || !is_destructive_tool(name) {
2296 return None;
2297 }
2298
2299 let mut receipt = String::from("[ACTION RECEIPT]\n");
2300 receipt.push_str(&format!("- tool: {}\n", name));
2301 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
2302 receipt.push_str(&format!("- target: {}\n", path));
2303 }
2304 if name == "shell" {
2305 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
2306 receipt.push_str(&format!("- command: {}\n", command));
2307 }
2308 if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
2309 if !reason.trim().is_empty() {
2310 receipt.push_str(&format!("- reason: {}\n", reason.trim()));
2311 }
2312 }
2313 }
2314 let first_line = output.lines().next().unwrap_or(output).trim();
2315 receipt.push_str(&format!("- outcome: {}\n", first_line));
2316 Some(ChatMessage::system(&receipt))
2317 }
2318
2319 fn replace_mcp_tool_definitions(&mut self, mcp_tools: &[crate::agent::mcp::McpTool]) {
2320 self.tools
2321 .retain(|tool| !tool.function.name.starts_with("mcp__"));
2322 self.tools
2323 .extend(mcp_tools.iter().map(|tool| ToolDefinition {
2324 tool_type: "function".into(),
2325 function: ToolFunction {
2326 name: tool.name.clone(),
2327 description: tool.description.clone().unwrap_or_default(),
2328 parameters: tool.input_schema.clone(),
2329 },
2330 metadata: crate::agent::inference::tool_metadata_for_name(&tool.name),
2331 }));
2332 }
2333
2334 async fn emit_mcp_runtime_status(&self, tx: &mpsc::Sender<InferenceEvent>) {
2335 let summary = {
2336 let mcp = self.mcp_manager.lock().await;
2337 mcp.runtime_report()
2338 };
2339 let _ = tx
2340 .send(InferenceEvent::McpStatus {
2341 state: summary.state,
2342 summary: summary.summary,
2343 })
2344 .await;
2345 }
2346
2347 async fn refresh_mcp_tools(
2348 &mut self,
2349 tx: &mpsc::Sender<InferenceEvent>,
2350 ) -> Result<Vec<crate::agent::mcp::McpTool>, Box<dyn std::error::Error + Send + Sync>> {
2351 let mcp_tools = {
2352 let mut mcp = self.mcp_manager.lock().await;
2353 match mcp.initialize_all().await {
2354 Ok(()) => mcp.discover_tools().await,
2355 Err(e) => {
2356 drop(mcp);
2357 self.replace_mcp_tool_definitions(&[]);
2358 self.emit_mcp_runtime_status(tx).await;
2359 return Err(e.into());
2360 }
2361 }
2362 };
2363
2364 self.replace_mcp_tool_definitions(&mcp_tools);
2365 self.emit_mcp_runtime_status(tx).await;
2366 Ok(mcp_tools)
2367 }
2368
2369 pub async fn initialize_mcp(
2371 &mut self,
2372 tx: &mpsc::Sender<InferenceEvent>,
2373 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2374 let _ = self.refresh_mcp_tools(tx).await?;
2375 Ok(())
2376 }
2377
2378 pub async fn run_turn(
2384 &mut self,
2385 user_turn: &UserTurn,
2386 tx: mpsc::Sender<InferenceEvent>,
2387 yolo: bool,
2388 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2389 let user_input = user_turn.text.as_str();
2390 if user_input.trim() == "/new" {
2392 self.history.clear();
2393 self.reasoning_history = None;
2394 self.session_memory.clear();
2395 self.running_summary = None;
2396 self.correction_hints.clear();
2397 self.pinned_files.lock().await.clear();
2398 self.reset_action_grounding().await;
2399 reset_task_files();
2400 let _ = std::fs::remove_file(session_path());
2401 self.save_empty_session();
2402 self.emit_compaction_pressure(&tx).await;
2403 self.emit_prompt_pressure_idle(&tx).await;
2404 for chunk in chunk_text(
2405 "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
2406 8,
2407 ) {
2408 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2409 }
2410 let _ = tx.send(InferenceEvent::Done).await;
2411 return Ok(());
2412 }
2413
2414 if user_input.trim() == "/forget" {
2415 self.history.clear();
2416 self.reasoning_history = None;
2417 self.session_memory.clear();
2418 self.running_summary = None;
2419 self.correction_hints.clear();
2420 self.pinned_files.lock().await.clear();
2421 self.reset_action_grounding().await;
2422 reset_task_files();
2423 purge_persistent_memory();
2424 tokio::task::block_in_place(|| self.vein.reset());
2425 let _ = std::fs::remove_file(session_path());
2426 self.save_empty_session();
2427 self.emit_compaction_pressure(&tx).await;
2428 self.emit_prompt_pressure_idle(&tx).await;
2429 for chunk in chunk_text(
2430 "Hard forget complete. Chat history, saved memory, task files, and the Vein index were purged.",
2431 8,
2432 ) {
2433 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2434 }
2435 let _ = tx.send(InferenceEvent::Done).await;
2436 return Ok(());
2437 }
2438
2439 if user_input.trim() == "/vein-inspect" {
2440 let indexed = self.refresh_vein_index();
2441 let report = self.build_vein_inspection_report(indexed);
2442 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
2443 let _ = tx
2444 .send(InferenceEvent::VeinStatus {
2445 file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
2446 embedded_count: snapshot.embedded_source_doc_chunks,
2447 docs_only: self.vein_docs_only_mode(),
2448 })
2449 .await;
2450 for chunk in chunk_text(&report, 8) {
2451 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2452 }
2453 let _ = tx.send(InferenceEvent::Done).await;
2454 return Ok(());
2455 }
2456
2457 if user_input.trim() == "/workspace-profile" {
2458 let root = crate::tools::file_ops::workspace_root();
2459 let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
2460 let report = crate::agent::workspace_profile::profile_report(&root);
2461 for chunk in chunk_text(&report, 8) {
2462 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2463 }
2464 let _ = tx.send(InferenceEvent::Done).await;
2465 return Ok(());
2466 }
2467
2468 if user_input.trim() == "/rules" {
2469 let rules_path = crate::tools::file_ops::hematite_dir().join("rules.md");
2470 let report = if rules_path.exists() {
2471 match std::fs::read_to_string(&rules_path) {
2472 Ok(content) => format!(
2473 "## 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.",
2474 content.trim()
2475 ),
2476 Err(e) => format!("Error reading .hematite/rules.md: {e}"),
2477 }
2478 } else {
2479 format!(
2480 "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: {}",
2481 rules_path.display()
2482 )
2483 };
2484 for chunk in chunk_text(&report, 8) {
2485 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2486 }
2487 let _ = tx.send(InferenceEvent::Done).await;
2488 return Ok(());
2489 }
2490
2491 if user_input.trim() == "/vein-reset" {
2492 tokio::task::block_in_place(|| self.vein.reset());
2493 let _ = tx
2494 .send(InferenceEvent::VeinStatus {
2495 file_count: 0,
2496 embedded_count: 0,
2497 docs_only: self.vein_docs_only_mode(),
2498 })
2499 .await;
2500 for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
2501 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2502 }
2503 let _ = tx.send(InferenceEvent::Done).await;
2504 return Ok(());
2505 }
2506
2507 let config = crate::agent::config::load_config();
2509 self.recovery_context.clear();
2510 let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
2511 if !manual_runtime_refresh {
2512 if let Some((model_id, context_length, changed)) = self
2513 .refresh_runtime_profile_and_report(&tx, "turn_start")
2514 .await
2515 {
2516 if changed {
2517 let _ = tx
2518 .send(InferenceEvent::Thought(format!(
2519 "Runtime refresh: using model `{}` with CTX {} for this turn.",
2520 model_id, context_length
2521 )))
2522 .await;
2523 }
2524 }
2525 }
2526 self.emit_compaction_pressure(&tx).await;
2527 let current_model = self.engine.current_model();
2528 self.engine.set_gemma_native_formatting(
2529 crate::agent::config::effective_gemma_native_formatting(&config, ¤t_model),
2530 );
2531 let _turn_id = self.begin_grounded_turn().await;
2532 let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
2533 let mcp_tools = match self.refresh_mcp_tools(&tx).await {
2534 Ok(tools) => tools,
2535 Err(e) => {
2536 let _ = tx
2537 .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
2538 .await;
2539 Vec::new()
2540 }
2541 };
2542
2543 let effective_fast = config
2545 .fast_model
2546 .clone()
2547 .or_else(|| self.fast_model.clone());
2548 let effective_think = config
2549 .think_model
2550 .clone()
2551 .or_else(|| self.think_model.clone());
2552
2553 if user_input.trim() == "/lsp" {
2555 let mut lsp = self.lsp_manager.lock().await;
2556 match lsp.start_servers().await {
2557 Ok(_) => {
2558 let _ = tx
2559 .send(InferenceEvent::MutedToken(
2560 "LSP: Servers Initialized OK.".to_string(),
2561 ))
2562 .await;
2563 }
2564 Err(e) => {
2565 let _ = tx
2566 .send(InferenceEvent::Error(format!(
2567 "LSP: Failed to start servers - {}",
2568 e
2569 )))
2570 .await;
2571 }
2572 }
2573 let _ = tx.send(InferenceEvent::Done).await;
2574 return Ok(());
2575 }
2576
2577 if user_input.trim() == "/runtime-refresh" {
2578 match self
2579 .refresh_runtime_profile_and_report(&tx, "manual_command")
2580 .await
2581 {
2582 Some((model_id, context_length, changed)) => {
2583 let msg = if changed {
2584 format!(
2585 "Runtime profile refreshed. Model: {} | CTX: {}",
2586 model_id, context_length
2587 )
2588 } else {
2589 format!(
2590 "Runtime profile unchanged. Model: {} | CTX: {}",
2591 model_id, context_length
2592 )
2593 };
2594 for chunk in chunk_text(&msg, 8) {
2595 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2596 }
2597 }
2598 None => {
2599 let _ = tx
2600 .send(InferenceEvent::Error(
2601 "Runtime refresh failed: LM Studio profile could not be read."
2602 .to_string(),
2603 ))
2604 .await;
2605 }
2606 }
2607 let _ = tx.send(InferenceEvent::Done).await;
2608 return Ok(());
2609 }
2610
2611 if user_input.trim() == "/ask" {
2612 self.set_workflow_mode(WorkflowMode::Ask);
2613 for chunk in chunk_text(
2614 "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
2615 8,
2616 ) {
2617 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2618 }
2619 let _ = tx.send(InferenceEvent::Done).await;
2620 return Ok(());
2621 }
2622
2623 if user_input.trim() == "/code" {
2624 self.set_workflow_mode(WorkflowMode::Code);
2625 let mut message =
2626 "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
2627 if let Some(plan) = self.current_plan_summary() {
2628 message.push_str(&format!(" Current plan: {plan}."));
2629 }
2630 for chunk in chunk_text(&message, 8) {
2631 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2632 }
2633 let _ = tx.send(InferenceEvent::Done).await;
2634 return Ok(());
2635 }
2636
2637 if user_input.trim() == "/architect" {
2638 self.set_workflow_mode(WorkflowMode::Architect);
2639 let mut message =
2640 "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();
2641 if let Some(plan) = self.current_plan_summary() {
2642 message.push_str(&format!(" Existing plan: {plan}."));
2643 }
2644 for chunk in chunk_text(&message, 8) {
2645 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2646 }
2647 let _ = tx.send(InferenceEvent::Done).await;
2648 return Ok(());
2649 }
2650
2651 if user_input.trim() == "/read-only" {
2652 self.set_workflow_mode(WorkflowMode::ReadOnly);
2653 for chunk in chunk_text(
2654 "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
2655 8,
2656 ) {
2657 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2658 }
2659 let _ = tx.send(InferenceEvent::Done).await;
2660 return Ok(());
2661 }
2662
2663 if user_input.trim() == "/auto" {
2664 self.set_workflow_mode(WorkflowMode::Auto);
2665 for chunk in chunk_text(
2666 "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
2667 8,
2668 ) {
2669 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2670 }
2671 let _ = tx.send(InferenceEvent::Done).await;
2672 return Ok(());
2673 }
2674
2675 if user_input.trim() == "/chat" {
2676 self.set_workflow_mode(WorkflowMode::Chat);
2677 let _ = tx.send(InferenceEvent::Done).await;
2678 return Ok(());
2679 }
2680
2681 if user_input.trim() == "/teach" {
2682 self.set_workflow_mode(WorkflowMode::Teach);
2683 for chunk in chunk_text(
2684 "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.",
2685 8,
2686 ) {
2687 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2688 }
2689 let _ = tx.send(InferenceEvent::Done).await;
2690 return Ok(());
2691 }
2692
2693 if user_input.trim() == "/reroll" {
2694 let soul = crate::ui::hatch::generate_soul_random();
2695 self.snark = soul.snark;
2696 self.chaos = soul.chaos;
2697 self.soul_personality = soul.personality.clone();
2698 let species = soul.species.clone();
2703 if let Some(eng) = Arc::get_mut(&mut self.engine) {
2704 eng.species = species.clone();
2705 }
2706 let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
2707 let _ = tx
2708 .send(InferenceEvent::SoulReroll {
2709 species: soul.species.clone(),
2710 rarity: soul.rarity.label().to_string(),
2711 shiny: soul.shiny,
2712 personality: soul.personality.clone(),
2713 })
2714 .await;
2715 for chunk in chunk_text(
2716 &format!(
2717 "A new companion awakens!\n[{}{}] {} — \"{}\"",
2718 soul.rarity.label(),
2719 shiny_tag,
2720 soul.species,
2721 soul.personality
2722 ),
2723 8,
2724 ) {
2725 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2726 }
2727 let _ = tx.send(InferenceEvent::Done).await;
2728 return Ok(());
2729 }
2730
2731 if user_input.trim() == "/agent" {
2732 self.set_workflow_mode(WorkflowMode::Auto);
2733 let _ = tx.send(InferenceEvent::Done).await;
2734 return Ok(());
2735 }
2736
2737 let implement_plan_alias = user_input.trim() == "/implement-plan";
2738 if implement_plan_alias
2739 && !self
2740 .session_memory
2741 .current_plan
2742 .as_ref()
2743 .map(|plan| plan.has_signal())
2744 .unwrap_or(false)
2745 {
2746 for chunk in chunk_text(
2747 "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
2748 8,
2749 ) {
2750 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2751 }
2752 let _ = tx.send(InferenceEvent::Done).await;
2753 return Ok(());
2754 }
2755
2756 let mut effective_user_input = if implement_plan_alias {
2757 self.set_workflow_mode(WorkflowMode::Code);
2758 implement_current_plan_prompt().to_string()
2759 } else {
2760 user_input.trim().to_string()
2761 };
2762 if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
2763 self.set_workflow_mode(mode);
2764 effective_user_input = rest.to_string();
2765 }
2766 let transcript_user_input = if implement_plan_alias {
2767 transcript_user_turn_text(user_turn, "/implement-plan")
2768 } else {
2769 transcript_user_turn_text(user_turn, &effective_user_input)
2770 };
2771 effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
2772 self.register_at_file_mentions(user_input).await;
2775 let implement_current_plan = self.workflow_mode == WorkflowMode::Code
2776 && is_current_plan_execution_request(&effective_user_input)
2777 && self
2778 .session_memory
2779 .current_plan
2780 .as_ref()
2781 .map(|plan| plan.has_signal())
2782 .unwrap_or(false);
2783 self.plan_execution_active
2784 .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
2785 let _plan_execution_guard = PlanExecutionGuard {
2786 flag: self.plan_execution_active.clone(),
2787 };
2788 let task_progress_before = if implement_current_plan {
2789 read_task_checklist_progress()
2790 } else {
2791 None
2792 };
2793 let current_plan_pass = if implement_current_plan {
2794 self.plan_execution_pass_depth
2795 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2796 + 1
2797 } else {
2798 0
2799 };
2800 let _plan_execution_pass_guard = implement_current_plan.then(|| PlanExecutionPassGuard {
2801 depth: self.plan_execution_pass_depth.clone(),
2802 });
2803 let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
2804
2805 if self.workflow_mode == WorkflowMode::Auto
2807 && intent.primary_class == QueryIntentClass::Research
2808 {
2809 self.set_workflow_mode(WorkflowMode::Ask);
2810 let _ = tx
2811 .send(InferenceEvent::Thought(
2812 "Seamless search detected: transitioning to investigation mode...".into(),
2813 ))
2814 .await;
2815 }
2816
2817 if let Some(answer_kind) = intent.direct_answer {
2819 match answer_kind {
2820 DirectAnswerKind::About => {
2821 let response = build_about_answer();
2822 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2823 .await;
2824 return Ok(());
2825 }
2826 DirectAnswerKind::LanguageCapability => {
2827 let response = build_language_capability_answer();
2828 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2829 .await;
2830 return Ok(());
2831 }
2832 DirectAnswerKind::UnsafeWorkflowPressure => {
2833 let response = build_unsafe_workflow_pressure_answer();
2834 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2835 .await;
2836 return Ok(());
2837 }
2838 DirectAnswerKind::SessionMemory => {
2839 let response = build_session_memory_answer();
2840 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2841 .await;
2842 return Ok(());
2843 }
2844 DirectAnswerKind::RecoveryRecipes => {
2845 let response = build_recovery_recipes_answer();
2846 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2847 .await;
2848 return Ok(());
2849 }
2850 DirectAnswerKind::McpLifecycle => {
2851 let response = build_mcp_lifecycle_answer();
2852 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2853 .await;
2854 return Ok(());
2855 }
2856 DirectAnswerKind::AuthorizationPolicy => {
2857 let response = build_authorization_policy_answer();
2858 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2859 .await;
2860 return Ok(());
2861 }
2862 DirectAnswerKind::ToolClasses => {
2863 let response = build_tool_classes_answer();
2864 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2865 .await;
2866 return Ok(());
2867 }
2868 DirectAnswerKind::ToolRegistryOwnership => {
2869 let response = build_tool_registry_ownership_answer();
2870 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2871 .await;
2872 return Ok(());
2873 }
2874 DirectAnswerKind::SessionResetSemantics => {
2875 let response = build_session_reset_semantics_answer();
2876 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2877 .await;
2878 return Ok(());
2879 }
2880 DirectAnswerKind::ProductSurface => {
2881 let response = build_product_surface_answer();
2882 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2883 .await;
2884 return Ok(());
2885 }
2886 DirectAnswerKind::ReasoningSplit => {
2887 let response = build_reasoning_split_answer();
2888 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2889 .await;
2890 return Ok(());
2891 }
2892 DirectAnswerKind::Identity => {
2893 let response = build_identity_answer();
2894 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2895 .await;
2896 return Ok(());
2897 }
2898 DirectAnswerKind::WorkflowModes => {
2899 let response = build_workflow_modes_answer();
2900 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2901 .await;
2902 return Ok(());
2903 }
2904 DirectAnswerKind::GemmaNative => {
2905 let response = build_gemma_native_answer();
2906 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2907 .await;
2908 return Ok(());
2909 }
2910 DirectAnswerKind::GemmaNativeSettings => {
2911 let response = build_gemma_native_settings_answer();
2912 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2913 .await;
2914 return Ok(());
2915 }
2916 DirectAnswerKind::VerifyProfiles => {
2917 let response = build_verify_profiles_answer();
2918 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2919 .await;
2920 return Ok(());
2921 }
2922 DirectAnswerKind::Toolchain => {
2923 let lower = effective_user_input.to_lowercase();
2924 let topic = if (lower.contains("voice output") || lower.contains("voice"))
2925 && (lower.contains("lag")
2926 || lower.contains("behind visible text")
2927 || lower.contains("latency"))
2928 {
2929 "voice_latency_plan"
2930 } else {
2931 "all"
2932 };
2933 let response =
2934 crate::tools::toolchain::describe_toolchain(&serde_json::json!({
2935 "topic": topic,
2936 "question": effective_user_input,
2937 }))
2938 .await
2939 .unwrap_or_else(|e| format!("Error: {}", e));
2940 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2941 .await;
2942 return Ok(());
2943 }
2944 DirectAnswerKind::HostInspection => {
2945 let topics = all_host_inspection_topics(&effective_user_input);
2946 let response = if topics.len() >= 2 {
2947 let mut combined = Vec::new();
2948 for topic in topics {
2949 let args =
2950 host_inspection_args_from_prompt(topic, &effective_user_input);
2951 let output = crate::tools::host_inspect::inspect_host(&args)
2952 .await
2953 .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
2954 combined.push(format!("# Topic: {topic}\n{output}"));
2955 }
2956 combined.join("\n\n---\n\n")
2957 } else {
2958 let topic = preferred_host_inspection_topic(&effective_user_input)
2959 .unwrap_or("summary");
2960 let args = host_inspection_args_from_prompt(topic, &effective_user_input);
2961 crate::tools::host_inspect::inspect_host(&args)
2962 .await
2963 .unwrap_or_else(|e| format!("Error: {e}"))
2964 };
2965
2966 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2967 .await;
2968 return Ok(());
2969 }
2970 DirectAnswerKind::ArchitectSessionResetPlan => {
2971 let plan = build_architect_session_reset_plan();
2972 let response = plan.to_markdown();
2973 let _ = crate::tools::plan::save_plan_handoff(&plan);
2974 self.session_memory.current_plan = Some(plan);
2975 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2976 .await;
2977 return Ok(());
2978 }
2979 }
2980 }
2981
2982 if matches!(
2983 self.workflow_mode,
2984 WorkflowMode::Ask | WorkflowMode::ReadOnly
2985 ) && looks_like_mutation_request(&effective_user_input)
2986 {
2987 let response = build_mode_redirect_answer(self.workflow_mode);
2988 self.history.push(ChatMessage::user(&effective_user_input));
2989 self.history.push(ChatMessage::assistant_text(&response));
2990 self.transcript.log_user(&transcript_user_input);
2991 self.transcript.log_agent(&response);
2992 for chunk in chunk_text(&response, 8) {
2993 if !chunk.is_empty() {
2994 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2995 }
2996 }
2997 let _ = tx.send(InferenceEvent::Done).await;
2998 self.trim_history(80);
2999 self.refresh_session_memory();
3000 self.save_session();
3001 return Ok(());
3002 }
3003
3004 if user_input.trim() == "/think" {
3005 self.think_mode = Some(true);
3006 for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
3007 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3008 }
3009 let _ = tx.send(InferenceEvent::Done).await;
3010 return Ok(());
3011 }
3012 if user_input.trim() == "/no_think" {
3013 self.think_mode = Some(false);
3014 for chunk in chunk_text(
3015 "Think mode: OFF — fast mode enabled (no chain-of-thought).",
3016 8,
3017 ) {
3018 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3019 }
3020 let _ = tx.send(InferenceEvent::Done).await;
3021 return Ok(());
3022 }
3023
3024 if user_input.trim_start().starts_with("/pin ") {
3026 let path = user_input.trim_start()[5..].trim();
3027 match std::fs::read_to_string(path) {
3028 Ok(content) => {
3029 self.pinned_files
3030 .lock()
3031 .await
3032 .insert(path.to_string(), content);
3033 let msg = format!(
3034 "Pinned: {} — this file is now locked in model context.",
3035 path
3036 );
3037 for chunk in chunk_text(&msg, 8) {
3038 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3039 }
3040 }
3041 Err(e) => {
3042 let _ = tx
3043 .send(InferenceEvent::Error(format!(
3044 "Failed to pin {}: {}",
3045 path, e
3046 )))
3047 .await;
3048 }
3049 }
3050 let _ = tx.send(InferenceEvent::Done).await;
3051 return Ok(());
3052 }
3053
3054 if user_input.trim_start().starts_with("/unpin ") {
3056 let path = user_input.trim_start()[7..].trim();
3057 if self.pinned_files.lock().await.remove(path).is_some() {
3058 let msg = format!("Unpinned: {} — file removed from active context.", path);
3059 for chunk in chunk_text(&msg, 8) {
3060 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3061 }
3062 } else {
3063 let _ = tx
3064 .send(InferenceEvent::Error(format!(
3065 "File {} was not pinned.",
3066 path
3067 )))
3068 .await;
3069 }
3070 let _ = tx.send(InferenceEvent::Done).await;
3071 return Ok(());
3072 }
3073
3074 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
3078 if let Some(root) = extract_sovereign_scaffold_root(&effective_user_input) {
3079 if std::fs::create_dir_all(&root).is_ok() {
3080 let targets = default_sovereign_scaffold_targets(&effective_user_input);
3081 let _ = seed_sovereign_scaffold_files(&root, &targets);
3082 let plan = build_sovereign_scaffold_handoff(&effective_user_input, &targets);
3083 let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &plan);
3084 let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
3085 let _ = write_sovereign_handoff_markdown(&root, &effective_user_input, &plan);
3086 self.pending_teleport_handoff = None;
3087 self.latest_target_dir = Some(root.to_string_lossy().to_string());
3088 let response = format!(
3089 "Created the sovereign project root at `{}` and wrote a local handoff. Teleporting now so the next session can continue implementation inside that project.",
3090 root.display()
3091 );
3092 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3093 .await;
3094 return Ok(());
3095 }
3096 }
3097 }
3098
3099 let tiny_context_mode = self.engine.current_context_length() <= 8_192;
3100 let mut base_prompt = self.engine.build_system_prompt(
3101 self.snark,
3102 self.chaos,
3103 self.brief,
3104 self.professional,
3105 &self.tools,
3106 self.reasoning_history.as_deref(),
3107 &mcp_tools,
3108 );
3109 if !tiny_context_mode {
3110 if let Some(hint) = &config.context_hint {
3111 if !hint.trim().is_empty() {
3112 base_prompt.push_str(&format!(
3113 "\n\n# Project Context (from .hematite/settings.json)\n{}",
3114 hint
3115 ));
3116 }
3117 }
3118 if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
3119 &crate::tools::file_ops::workspace_root(),
3120 ) {
3121 base_prompt.push_str(&format!("\n\n{}", profile_block));
3122 }
3123 if let Some(strategy_block) =
3124 crate::agent::workspace_profile::profile_strategy_prompt_block(
3125 &crate::tools::file_ops::workspace_root(),
3126 )
3127 {
3128 base_prompt.push_str(&format!("\n\n{}", strategy_block));
3129 }
3130 if let Some(ref l1) = self.l1_context {
3132 base_prompt.push_str(&format!("\n\n{}", l1));
3133 }
3134 if let Some(ref repo_map_block) = self.repo_map {
3135 base_prompt.push_str(&format!("\n\n{}", repo_map_block));
3136 }
3137 }
3138 let grounded_trace_mode = intent.grounded_trace_mode
3139 || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
3140 let capability_mode =
3141 intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
3142 let toolchain_mode =
3143 intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
3144 let host_inspection_mode = if intent.host_inspection_mode {
3149 let api_url = self.engine.base_url.clone();
3150 let query = effective_user_input.clone();
3151 let embed_class = tokio::time::timeout(
3152 std::time::Duration::from_millis(600),
3153 crate::agent::intent_embed::classify_intent(&query, &api_url),
3154 )
3155 .await
3156 .unwrap_or(crate::agent::intent_embed::IntentClass::Ambiguous);
3157 !matches!(
3158 embed_class,
3159 crate::agent::intent_embed::IntentClass::Advisory
3160 )
3161 } else {
3162 false
3163 };
3164 let maintainer_workflow_mode = intent.maintainer_workflow_mode
3165 || preferred_maintainer_workflow(&effective_user_input).is_some();
3166 let research_mode = intent.primary_class == QueryIntentClass::Research;
3167 let fix_plan_mode =
3168 preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
3169 let architecture_overview_mode = intent.architecture_overview_mode;
3170 let capability_needs_repo = intent.capability_needs_repo;
3171 let mut system_msg = build_system_with_corrections(
3172 &base_prompt,
3173 &self.correction_hints,
3174 &self.gpu_state,
3175 &self.git_state,
3176 &config,
3177 );
3178 if !tiny_context_mode && research_mode {
3179 system_msg.push_str(
3180 "\n\n# RESEARCH MODE\n\
3181 This turn is an investigation into external technical information.\n\
3182 Prioritize using the `research_web` tool to find the most current and authoritative data.\n\
3183 When providing information, ground your answer in the search results and cite your sources if possible.\n\
3184 If the user's question involves specific versions or recent releases (e.g., Rust compiler), use the web to verify the exact state.\n"
3185 );
3186 }
3187 if tiny_context_mode {
3188 system_msg.push_str(
3189 "\n\n# TINY CONTEXT TURN MODE\n\
3190 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
3191 );
3192 }
3193 if !tiny_context_mode && grounded_trace_mode {
3194 system_msg.push_str(
3195 "\n\n# GROUNDED TRACE MODE\n\
3196 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
3197 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
3198 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
3199 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
3200 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
3201 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
3202 Do not invent names such as synthetic channels or subsystems.\n\
3203 If a detail is not verified from the code or tool output, say `uncertain`.\n\
3204 For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
3205 );
3206 }
3207 if !tiny_context_mode && capability_mode {
3208 }
3210 if !tiny_context_mode && toolchain_mode {
3211 }
3213 if !tiny_context_mode && host_inspection_mode {
3214 }
3216 if !tiny_context_mode && fix_plan_mode {
3217 system_msg.push_str(
3218 "\n\n# FIX PLAN MODE\n\
3219 This turn is a workstation remediation question, not just a diagnosis question.\n\
3220 Call `inspect_host` with `topic=fix_plan` first.\n\
3221 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
3222 Keep the answer grounded, stepwise, and approval-aware.\n"
3223 );
3224 }
3225 if !tiny_context_mode && maintainer_workflow_mode {
3226 system_msg.push_str(
3227 "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
3228 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
3229 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
3230 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\
3231 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"
3232 );
3233 }
3234 if !tiny_context_mode && architecture_overview_mode {
3237 system_msg.push_str(
3238 "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
3239 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
3240 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\
3241 Preserve grounded tool output rather than restyling it into a larger answer.\n"
3242 );
3243 }
3244
3245 system_msg.push_str(&format!(
3247 "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
3248 self.workflow_mode.label()
3249 ));
3250 if tiny_context_mode {
3251 system_msg
3252 .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
3253 } else {
3254 }
3255 if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
3256 system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
3257 system_msg.push_str(architect_handoff_contract());
3258 system_msg.push('\n');
3259 }
3260 if !tiny_context_mode && is_scaffold_request(&effective_user_input) {
3261 system_msg.push_str(scaffold_protocol());
3262 }
3263 if !tiny_context_mode && implement_current_plan {
3264 system_msg.push_str(
3265 "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
3266 The user explicitly asked you to implement the current saved plan.\n\
3267 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
3268 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
3269 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
3270 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",
3271 );
3272 if let Some(plan) = self.session_memory.current_plan.as_ref() {
3273 if !plan.target_files.is_empty() {
3274 system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
3275 for path in &plan.target_files {
3276 system_msg.push_str(&format!("- {}\n", path));
3277 }
3278 }
3279 }
3280 }
3281 if !tiny_context_mode {
3282 let pinned = self.pinned_files.lock().await;
3283 if !pinned.is_empty() {
3284 system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
3285 system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
3286 for (path, content) in pinned.iter() {
3287 system_msg.push_str(&format!("## FILE: {}\n```\n{}\n```\n\n", path, content));
3288 }
3289 }
3290 }
3291 if !tiny_context_mode {
3292 self.append_session_handoff(&mut system_msg);
3293 }
3294 let mut final_system_msg = if self.workflow_mode.is_chat() {
3296 self.build_chat_system_prompt()
3297 } else {
3298 system_msg
3299 };
3300
3301 if !tiny_context_mode
3302 && matches!(self.workflow_mode, WorkflowMode::Code | WorkflowMode::Auto)
3303 {
3304 let task_path = std::path::Path::new(".hematite/TASK.md");
3305 if task_path.exists() {
3306 if let Ok(content) = std::fs::read_to_string(task_path) {
3307 let snippet = if content.lines().count() > 50 {
3308 content.lines().take(50).collect::<Vec<_>>().join("\n")
3309 + "\n... (truncated)"
3310 } else {
3311 content
3312 };
3313 final_system_msg.push_str("\n\n# CURRENT TASK STATUS (.hematite/TASK.md)\n");
3314 final_system_msg.push_str("Update this file via `edit_file` to check off `[x]` items as you complete them.\n");
3315 final_system_msg.push_str("```markdown\n");
3316 final_system_msg.push_str(&snippet);
3317 final_system_msg.push_str("\n```\n");
3318 }
3319 }
3320 }
3321
3322 let system_msg = final_system_msg;
3323 if self.history.is_empty() || self.history[0].role != "system" {
3324 self.history.insert(0, ChatMessage::system(&system_msg));
3325 } else {
3326 self.history[0] = ChatMessage::system(&system_msg);
3327 }
3328
3329 self.cancel_token
3331 .store(false, std::sync::atomic::Ordering::SeqCst);
3332
3333 self.reasoning_history = None;
3336
3337 let is_gemma =
3338 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
3339 let user_content = match self.think_mode {
3340 Some(true) => format!("/think\n{}", effective_user_input),
3341 Some(false) => format!("/no_think\n{}", effective_user_input),
3342 None if !is_gemma
3347 && !self.workflow_mode.is_chat()
3348 && !is_quick_tool_request(&effective_user_input) =>
3349 {
3350 format!("/think\n{}", effective_user_input)
3351 }
3352 None => effective_user_input.clone(),
3353 };
3354 if let Some(image) = user_turn.attached_image.as_ref() {
3355 let image_url =
3356 crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
3357 .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
3358 self.history
3359 .push(ChatMessage::user_with_image(&user_content, &image_url));
3360 } else {
3361 self.history.push(ChatMessage::user(&user_content));
3362 }
3363 self.transcript.log_user(&transcript_user_input);
3364
3365 let vein_docs_only = self.vein_docs_only_mode();
3369 let allow_vein_context = !self.workflow_mode.is_chat()
3370 || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
3371 let (vein_context, vein_paths) = if allow_vein_context {
3372 self.refresh_vein_index();
3373 let _ = tx
3374 .send(InferenceEvent::VeinStatus {
3375 file_count: self.vein.file_count(),
3376 embedded_count: self.vein.embedded_chunk_count(),
3377 docs_only: vein_docs_only,
3378 })
3379 .await;
3380 match self.build_vein_context(&effective_user_input) {
3381 Some((ctx, paths)) => (Some(ctx), paths),
3382 None => (None, Vec::new()),
3383 }
3384 } else {
3385 (None, Vec::new())
3386 };
3387 if !vein_paths.is_empty() {
3388 let _ = tx
3389 .send(InferenceEvent::VeinContext { paths: vein_paths })
3390 .await;
3391 }
3392
3393 let routed_model = route_model(
3395 &effective_user_input,
3396 effective_fast.as_deref(),
3397 effective_think.as_deref(),
3398 )
3399 .map(|s| s.to_string());
3400
3401 let mut loop_intervention: Option<String> = None;
3402
3403 {
3410 let topics = all_host_inspection_topics(&effective_user_input);
3411 if topics.len() >= 2 {
3412 let _ = tx
3413 .send(InferenceEvent::Thought(format!(
3414 "Harness pre-run: {} host inspection topics detected — running all before model turn.",
3415 topics.len()
3416 )))
3417 .await;
3418
3419 let topic_list = topics.join(", ");
3420 let mut combined = format!(
3421 "## HARNESS PRE-RUN RESULTS\n\
3422 The harness already ran inspect_host for the following topics: {topic_list}.\n\
3423 Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
3424 );
3425
3426 let mut tool_calls = Vec::new();
3427 let mut tool_msgs = Vec::new();
3428
3429 for topic in &topics {
3430 let call_id = format!("prerun_{topic}");
3431 let mut args_val =
3432 host_inspection_args_from_prompt(topic, &effective_user_input);
3433 args_val
3434 .as_object_mut()
3435 .unwrap()
3436 .insert("max_entries".to_string(), Value::from(20));
3437 let args_str = serde_json::to_string(&args_val).unwrap_or_default();
3438
3439 tool_calls.push(crate::agent::inference::ToolCallResponse {
3440 id: call_id.clone(),
3441 call_type: "function".to_string(),
3442 function: crate::agent::inference::ToolCallFn {
3443 name: "inspect_host".to_string(),
3444 arguments: args_str,
3445 },
3446 });
3447
3448 let label = format!("### inspect_host(topic=\"{topic}\")\n");
3449 let _ = tx
3450 .send(InferenceEvent::ToolCallStart {
3451 id: call_id.clone(),
3452 name: "inspect_host".to_string(),
3453 args: format!("inspect host {topic}"),
3454 })
3455 .await;
3456
3457 match crate::tools::host_inspect::inspect_host(&args_val).await {
3458 Ok(out) => {
3459 let _ = tx
3460 .send(InferenceEvent::ToolCallResult {
3461 id: call_id.clone(),
3462 name: "inspect_host".to_string(),
3463 output: out.chars().take(300).collect::<String>() + "...",
3464 is_error: false,
3465 })
3466 .await;
3467 combined.push_str(&label);
3468 combined.push_str(&out);
3469 combined.push_str("\n\n");
3470 tool_msgs.push(ChatMessage::tool_result_for_model(
3471 &call_id,
3472 "inspect_host",
3473 &out,
3474 &self.engine.current_model(),
3475 ));
3476 }
3477 Err(e) => {
3478 let err_msg = format!("Error: {e}");
3479 combined.push_str(&label);
3480 combined.push_str(&err_msg);
3481 combined.push_str("\n\n");
3482 tool_msgs.push(ChatMessage::tool_result_for_model(
3483 &call_id,
3484 "inspect_host",
3485 &err_msg,
3486 &self.engine.current_model(),
3487 ));
3488 }
3489 }
3490 }
3491
3492 self.history
3494 .push(ChatMessage::assistant_tool_calls("", tool_calls));
3495 for msg in tool_msgs {
3496 self.history.push(msg);
3497 }
3498
3499 loop_intervention = Some(combined);
3500 }
3501 }
3502
3503 if loop_intervention.is_none() && research_mode {
3509 let search_query = effective_user_input
3511 .to_lowercase()
3512 .replace("google ", "")
3513 .replace("search for ", "")
3514 .replace("look up ", "")
3515 .replace("lookup ", "")
3516 .trim()
3517 .to_string();
3518
3519 let _ = tx
3520 .send(InferenceEvent::Thought(
3521 "Research pre-run: executing search before model turn to ground the answer..."
3522 .into(),
3523 ))
3524 .await;
3525
3526 let call_id = "prerun_research".to_string();
3527 let args = serde_json::json!({ "query": search_query });
3528
3529 let _ = tx
3530 .send(InferenceEvent::ToolCallStart {
3531 id: call_id.clone(),
3532 name: "research_web".to_string(),
3533 args: format!("research_web: {}", search_query),
3534 })
3535 .await;
3536
3537 match crate::tools::research::execute_search(&args, config.searx_url.clone()).await {
3538 Ok(results)
3539 if !results.is_empty() && !results.contains("No search results found") =>
3540 {
3541 let _ = tx
3542 .send(InferenceEvent::ToolCallResult {
3543 id: call_id.clone(),
3544 name: "research_web".to_string(),
3545 output: results.chars().take(300).collect::<String>() + "...",
3546 is_error: false,
3547 })
3548 .await;
3549
3550 self.history.push(ChatMessage::assistant_tool_calls(
3552 "",
3553 vec![crate::agent::inference::ToolCallResponse {
3554 id: call_id.clone(),
3555 call_type: "function".to_string(),
3556 function: crate::agent::inference::ToolCallFn {
3557 name: "research_web".to_string(),
3558 arguments: serde_json::to_string(&args).unwrap_or_default(),
3559 },
3560 }],
3561 ));
3562 self.history.push(ChatMessage::tool_result_for_model(
3563 &call_id,
3564 "research_web",
3565 &results,
3566 &self.engine.current_model(),
3567 ));
3568
3569 loop_intervention = Some(format!(
3570 "## RESEARCH PRE-RUN RESULTS\n\
3571 The harness already ran `research_web` for your query.\n\
3572 Use the search results above to answer the user's question with grounded, factual information.\n\
3573 Do NOT re-run `research_web` unless you need additional detail.\n\
3574 Do NOT hallucinate or guess — base your answer entirely on the search results.\n"
3575 ));
3576 }
3577 Ok(_) | Err(_) => {
3578 let _ = tx
3580 .send(InferenceEvent::ToolCallResult {
3581 id: call_id.clone(),
3582 name: "research_web".to_string(),
3583 output: "No results found — model will attempt its own search.".into(),
3584 is_error: true,
3585 })
3586 .await;
3587 }
3588 }
3589 }
3590
3591 if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
3598 loop_intervention = Some(
3599 "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
3600 Do NOT answer from training-data memory — memory answers for math are guesses. \
3601 Use `run_code` to compute the real result and return the actual output. \
3602 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
3603 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
3604 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
3605 Write the code, run it, return the result."
3606 .to_string(),
3607 );
3608 }
3609
3610 if loop_intervention.is_none() && intent.surgical_filesystem_mode {
3612 loop_intervention = Some(
3613 "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
3614 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
3615 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
3616 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
3617 .to_string(),
3618 );
3619 }
3620
3621 if loop_intervention.is_none()
3626 && self.workflow_mode == WorkflowMode::Auto
3627 && is_scaffold_request(&effective_user_input)
3628 && !implement_current_plan
3629 {
3630 loop_intervention = Some(
3631 "AUTO-ARCHITECT: This request involves building multiple files (a scaffold). \
3632 Before implementing, draft a concise blueprint to `.hematite/PLAN.md` using `write_file`. \
3633 The blueprint should list:\n\
3634 1. The target directory path\n\
3635 2. Each file to create (with a one-line description of its purpose)\n\
3636 3. Key design decisions (e.g. color scheme, layout approach)\n\n\
3637 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for path accuracy.\n\
3638 After writing the PLAN.md, respond with a brief summary of what you planned. \
3639 Do NOT start implementing yet — just write the plan."
3640 .to_string(),
3641 );
3642 }
3643
3644 let mut implementation_started = false;
3645 let mut plan_drafted_this_turn = false;
3646 let mut non_mutating_plan_steps = 0usize;
3647 let non_mutating_plan_soft_cap = 5usize;
3648 let non_mutating_plan_hard_cap = 8usize;
3649 let mut overview_runtime_trace: Option<String> = None;
3650
3651 let max_iters = 25;
3653 let mut consecutive_errors = 0;
3654 let mut empty_cleaned_nudges = 0u8;
3655 let mut first_iter = true;
3656 let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
3657 let _result_counts: std::collections::HashMap<String, usize> =
3659 std::collections::HashMap::new();
3660 let mut repeat_counts: std::collections::HashMap<String, usize> =
3662 std::collections::HashMap::new();
3663 let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
3664 std::collections::HashMap::new();
3665 let mut successful_read_targets: std::collections::HashSet<String> =
3666 std::collections::HashSet::new();
3667 let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
3669 std::collections::HashSet::new();
3670 let mut successful_grep_targets: std::collections::HashSet<String> =
3671 std::collections::HashSet::new();
3672 let mut no_match_grep_targets: std::collections::HashSet<String> =
3673 std::collections::HashSet::new();
3674 let mut broad_grep_targets: std::collections::HashSet<String> =
3675 std::collections::HashSet::new();
3676 let mut sovereign_task_root: Option<String> = None;
3677 let mut sovereign_scaffold_targets: std::collections::BTreeSet<String> =
3678 std::collections::BTreeSet::new();
3679 let mut turn_mutated_paths: std::collections::BTreeSet<String> =
3680 std::collections::BTreeSet::new();
3681 let mut mutation_counts_by_path: std::collections::HashMap<String, usize> =
3682 std::collections::HashMap::new();
3683 let mut frontend_polish_intervention_emitted = false;
3684 let mut visible_closeout_emitted = false;
3685
3686 let mut turn_anchor = self.history.len().saturating_sub(1);
3688
3689 for _iter in 0..max_iters {
3690 let mut mutation_occurred = false;
3691 if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
3693 self.cancel_token
3694 .store(false, std::sync::atomic::Ordering::SeqCst);
3695 let _ = tx
3696 .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
3697 .await;
3698 let _ = tx.send(InferenceEvent::Done).await;
3699 return Ok(());
3700 }
3701
3702 if self
3704 .compact_history_if_needed(&tx, Some(turn_anchor))
3705 .await?
3706 {
3707 turn_anchor = 2;
3710 }
3711
3712 let inject_vein = first_iter && !implement_current_plan;
3716 let messages = if implement_current_plan {
3717 first_iter = false;
3718 self.context_window_slice_from(turn_anchor)
3719 } else {
3720 first_iter = false;
3721 self.context_window_slice()
3722 };
3723
3724 let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
3728 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
3731 let mut msgs = vec![self.history[0].clone()];
3732 msgs.push(ChatMessage::system(&intervention));
3733 msgs
3734 } else {
3735 let merged =
3736 format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
3737 vec![ChatMessage::system(&merged)]
3738 }
3739 } else {
3740 vec![self.history[0].clone()]
3741 };
3742
3743 if inject_vein {
3747 if let Some(ref ctx) = vein_context.as_ref() {
3748 if crate::agent::inference::is_hematite_native_model(
3749 &self.engine.current_model(),
3750 ) {
3751 prompt_msgs.push(ChatMessage::system(ctx));
3752 } else {
3753 let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
3754 prompt_msgs[0] = ChatMessage::system(&merged);
3755 }
3756 }
3757 }
3758 if let Some(root) = sovereign_task_root.as_ref() {
3759 let sovereign_root_instruction = format!(
3760 "EFFECTIVE TASK ROOT: This sovereign scaffold turn is now rooted at:\n\
3761 `{root}`\n\n\
3762 Treat that directory as the active project root for the rest of this turn. \
3763 All reads, writes, verification, and summaries must stay scoped to that root. \
3764 Ignore unrelated repo context such as `./src` unless the user explicitly asks about it. \
3765 Keep building within this sovereign root instead of reasoning from the original workspace."
3766 );
3767 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
3768 prompt_msgs.push(ChatMessage::system(&sovereign_root_instruction));
3769 } else {
3770 let merged = format!(
3771 "{}\n\n{}",
3772 prompt_msgs[0].content.as_str(),
3773 sovereign_root_instruction
3774 );
3775 prompt_msgs[0] = ChatMessage::system(&merged);
3776 }
3777 }
3778 prompt_msgs.extend(messages);
3779 if let Some(budget_note) =
3780 enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
3781 {
3782 self.emit_operator_checkpoint(
3783 &tx,
3784 OperatorCheckpointState::BudgetReduced,
3785 budget_note,
3786 )
3787 .await;
3788 let recipe = plan_recovery(
3789 RecoveryScenario::PromptBudgetPressure,
3790 &self.recovery_context,
3791 );
3792 self.emit_recovery_recipe_summary(
3793 &tx,
3794 recipe.recipe.scenario.label(),
3795 compact_recovery_plan_summary(&recipe),
3796 )
3797 .await;
3798 }
3799 self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
3800 .await;
3801
3802 let turn_tools = if yolo {
3803 Vec::new()
3805 } else if intent.sovereign_mode {
3806 self.tools
3807 .iter()
3808 .filter(|t| {
3809 t.function.name != "shell" && t.function.name != "run_workspace_workflow"
3810 })
3811 .cloned()
3812 .collect::<Vec<_>>()
3813 } else {
3814 self.tools.clone()
3815 };
3816
3817 let (mut text, mut tool_calls, usage, finish_reason) = match self
3818 .engine
3819 .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
3820 .await
3821 {
3822 Ok(result) => result,
3823 Err(e) => {
3824 let class = classify_runtime_failure(&e);
3825 if should_retry_runtime_failure(class) {
3826 if self.recovery_context.consume_transient_retry() {
3827 let label = match class {
3828 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
3829 _ => "empty_model_response",
3830 };
3831 self.transcript.log_system(&format!(
3832 "Automatic provider recovery triggered: {}",
3833 e.trim()
3834 ));
3835 self.emit_recovery_recipe_summary(
3836 &tx,
3837 label,
3838 compact_runtime_recovery_summary(class),
3839 )
3840 .await;
3841 let _ = tx
3842 .send(InferenceEvent::ProviderStatus {
3843 state: ProviderRuntimeState::Recovering,
3844 summary: compact_runtime_recovery_summary(class).into(),
3845 })
3846 .await;
3847 self.emit_operator_checkpoint(
3848 &tx,
3849 OperatorCheckpointState::RecoveringProvider,
3850 compact_runtime_recovery_summary(class),
3851 )
3852 .await;
3853 continue;
3854 }
3855 }
3856
3857 self.emit_runtime_failure(&tx, class, &e).await;
3858 break;
3859 }
3860 };
3861 self.emit_provider_live(&tx).await;
3862
3863 if text.is_none() && tool_calls.is_none() {
3868 if let Some(reasoning) = usage.as_ref().and_then(|u| {
3869 if u.completion_tokens > 2000 {
3870 Some(u.completion_tokens)
3871 } else {
3872 None
3873 }
3874 }) {
3875 self.emit_operator_checkpoint(
3876 &tx,
3877 OperatorCheckpointState::BlockedToolLoop,
3878 format!(
3879 "Reasoning collapse detected ({} tokens of empty output).",
3880 reasoning
3881 ),
3882 )
3883 .await;
3884 break;
3885 }
3886 }
3887
3888 if let Some(ref u) = usage {
3890 let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
3891 }
3892
3893 if tool_calls
3896 .as_ref()
3897 .map(|calls| calls.is_empty())
3898 .unwrap_or(true)
3899 {
3900 if let Some(raw_text) = text.as_deref() {
3901 let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
3902 if !native_calls.is_empty() {
3903 tool_calls = Some(native_calls);
3904 let stripped =
3905 crate::agent::inference::strip_native_tool_call_text(raw_text);
3906 text = if stripped.trim().is_empty() {
3907 None
3908 } else {
3909 Some(stripped)
3910 };
3911 }
3912 }
3913 }
3914
3915 let tool_calls = tool_calls.filter(|c| !c.is_empty());
3918 let near_context_ceiling = usage
3919 .as_ref()
3920 .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
3921 .unwrap_or(false);
3922
3923 if let Some(calls) = tool_calls {
3924 let (calls, prune_trace_note) =
3925 prune_architecture_trace_batch(calls, architecture_overview_mode);
3926 if let Some(note) = prune_trace_note {
3927 let _ = tx.send(InferenceEvent::Thought(note)).await;
3928 }
3929
3930 let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
3931 calls,
3932 self.workflow_mode.is_read_only(),
3933 architecture_overview_mode,
3934 );
3935 if let Some(note) = prune_bloat_note {
3936 let _ = tx.send(InferenceEvent::Thought(note)).await;
3937 }
3938
3939 let (calls, prune_note) = prune_authoritative_tool_batch(
3940 calls,
3941 grounded_trace_mode,
3942 &effective_user_input,
3943 );
3944 if let Some(note) = prune_note {
3945 let _ = tx.send(InferenceEvent::Thought(note)).await;
3946 }
3947
3948 let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
3949 if let Some(note) = prune_redir_note {
3950 let _ = tx.send(InferenceEvent::Thought(note)).await;
3951 }
3952
3953 let (calls, batch_note) = order_batch_reads_first(calls);
3954 if let Some(note) = batch_note {
3955 let _ = tx.send(InferenceEvent::Thought(note)).await;
3956 }
3957
3958 if let Some(repeated_path) = calls
3959 .iter()
3960 .filter_map(|c| repeated_read_target(&c.function))
3961 .find(|path| successful_read_targets.contains(path))
3962 {
3963 let repeated_path = repeated_path.to_string();
3964
3965 let err_msg = format!(
3966 "Read discipline: You already read `{}` recently. Use `inspect_lines` on a specific window or `grep_files` to find content, then continue with your edit.",
3967 repeated_path
3968 );
3969 let _ = tx
3970 .clone()
3971 .send(InferenceEvent::Token(format!("\n⚠️ {}\n", err_msg)))
3972 .await;
3973 let _ = tx
3974 .clone()
3975 .send(InferenceEvent::Thought(format!(
3976 "Intervention: {}",
3977 err_msg
3978 )))
3979 .await;
3980
3981 for call in &calls {
3984 self.history.push(ChatMessage::tool_result_for_model(
3985 &call.id,
3986 &call.function.name,
3987 &err_msg,
3988 &self.engine.current_model(),
3989 ));
3990 }
3991 self.emit_done_events(&tx).await;
3992 return Ok(());
3993 }
3994
3995 if capability_mode
3996 && !capability_needs_repo
3997 && calls
3998 .iter()
3999 .all(|c| is_capability_probe_tool(&c.function.name))
4000 {
4001 loop_intervention = Some(
4002 "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
4003 Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
4004 Do not mention raw `mcp__*` names unless they are active and directly relevant."
4005 .to_string(),
4006 );
4007 let _ = tx.clone()
4008 .send(InferenceEvent::Thought(
4009 "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
4010 .into(),
4011 ))
4012 .await;
4013 continue;
4014 }
4015
4016 let raw_content = text.as_deref().unwrap_or(" ");
4019
4020 if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
4021 let _ = tx
4022 .clone()
4023 .send(InferenceEvent::Thought(thought.clone()))
4024 .await;
4025 self.reasoning_history = Some(thought);
4027 }
4028
4029 let stored_tool_call_content = if implement_current_plan {
4032 cap_output(raw_content, 1200)
4033 } else {
4034 raw_content.to_string()
4035 };
4036 self.history.push(ChatMessage::assistant_tool_calls(
4037 &stored_tool_call_content,
4038 calls.clone(),
4039 ));
4040
4041 let mut results = Vec::new();
4043 let gemma4_model =
4044 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
4045 let latest_user_prompt = self.latest_user_prompt();
4046 let mut seen_call_keys = std::collections::HashSet::new();
4047 let mut deduped_calls = Vec::new();
4048 for call in calls.clone() {
4049 let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
4050 &call.function.name,
4051 &call.function.arguments,
4052 gemma4_model,
4053 latest_user_prompt,
4054 );
4055
4056 if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
4058 let cmd_val = normalized_args
4059 .get("command")
4060 .or_else(|| normalized_args.get("workflow"));
4061
4062 if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
4063 if cfg!(windows)
4064 && (cmd.contains("/dev/")
4065 || cmd.contains("/etc/")
4066 || cmd.contains("/var/"))
4067 {
4068 let err_msg = "STRICT: You are attempting to use Linux system paths (/dev, /etc, /var) on a Windows host. This is a reasoning collapse. Use relative paths within your workspace only.";
4069 let _ = tx
4070 .clone()
4071 .send(InferenceEvent::Token(format!("\n🚨 {}\n", err_msg)))
4072 .await;
4073 let _ = tx
4074 .clone()
4075 .send(InferenceEvent::Thought(format!(
4076 "Panic blocked: {}",
4077 err_msg
4078 )))
4079 .await;
4080
4081 let mut err_results = Vec::new();
4083 for c in &calls {
4084 err_results.push(ChatMessage::tool_result_for_model(
4085 &c.id,
4086 &c.function.name,
4087 err_msg,
4088 &self.engine.current_model(),
4089 ));
4090 }
4091 for res in err_results {
4092 self.history.push(res);
4093 }
4094 self.emit_done_events(&tx).await;
4095 return Ok(());
4096 }
4097
4098 if is_natural_language_hallucination(cmd) {
4099 let err_msg = format!(
4100 "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
4101 Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
4102 Use the correct surgical tool (like `create_directory`) instead of overthinking.",
4103 cmd
4104 );
4105 let _ = tx
4106 .send(InferenceEvent::Thought(format!(
4107 "Sanitizer error: {}",
4108 err_msg
4109 )))
4110 .await;
4111 results.push(ToolExecutionOutcome {
4112 call_id: call.id.clone(),
4113 tool_name: normalized_name.clone(),
4114 args: normalized_args.clone(),
4115 output: err_msg,
4116 is_error: true,
4117 blocked_by_policy: false,
4118 msg_results: Vec::new(),
4119 latest_target_dir: None,
4120 plan_drafted_this_turn: false,
4121 parsed_plan_handoff: None,
4122 });
4123 continue;
4124 }
4125 }
4126 }
4127
4128 let key = canonical_tool_call_key(&normalized_name, &normalized_args);
4129 if seen_call_keys.insert(key) {
4130 let repeat_guard_exempt = matches!(
4131 normalized_name.as_str(),
4132 "verify_build" | "git_commit" | "git_push"
4133 );
4134 if !repeat_guard_exempt {
4135 if let Some(cached) = completed_tool_cache
4136 .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
4137 {
4138 let _ = tx
4139 .send(InferenceEvent::Thought(
4140 "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
4141 .to_string(),
4142 ))
4143 .await;
4144 loop_intervention = Some(format!(
4145 "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.",
4146 cached.tool_name
4147 ));
4148 continue;
4149 }
4150 }
4151 deduped_calls.push(call);
4152 } else {
4153 let _ = tx
4154 .send(InferenceEvent::Thought(
4155 "Duplicate tool call skipped: identical built-in invocation already ran this turn."
4156 .to_string(),
4157 ))
4158 .await;
4159 }
4160 }
4161
4162 let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
4164 .into_iter()
4165 .partition(|c| is_parallel_safe(&c.function.name));
4166
4167 if !parallel_calls.is_empty() {
4169 let mut tasks = Vec::new();
4170 for call in parallel_calls {
4171 let tx_clone = tx.clone();
4172 let config_clone = config.clone();
4173 let call_with_id = call.clone();
4175 tasks.push(self.process_tool_call(
4176 call_with_id.function,
4177 config_clone,
4178 yolo,
4179 tx_clone,
4180 call_with_id.id,
4181 ));
4182 }
4183 results.extend(futures::future::join_all(tasks).await);
4185 }
4186
4187 let mut sovereign_bootstrap_complete = false;
4189
4190 for call in serial_calls {
4191 let outcome = self
4192 .process_tool_call(call.function, config.clone(), yolo, tx.clone(), call.id)
4193 .await;
4194
4195 if !outcome.is_error {
4196 let tool_name = outcome.tool_name.as_str();
4197 if matches!(
4198 tool_name,
4199 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4200 ) {
4201 if let Some(target) = action_target_path(tool_name, &outcome.args) {
4202 let normalized_path = normalize_workspace_path(&target);
4203 let rewrite_count = mutation_counts_by_path
4204 .entry(normalized_path.clone())
4205 .and_modify(|count| *count += 1)
4206 .or_insert(1);
4207
4208 let is_frontend_asset = [
4209 ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue",
4210 ".svelte",
4211 ]
4212 .iter()
4213 .any(|ext| normalized_path.ends_with(ext));
4214
4215 if is_frontend_asset && *rewrite_count >= 3 {
4216 frontend_polish_intervention_emitted = true;
4217 loop_intervention = Some(format!(
4218 "REWRITE LIMIT REACHED. You have updated `{}` {} times this turn. To prevent reasoning collapse, further rewrites to this file are blocked. \
4219 Please UPDATE `.hematite/TASK.md` to check off these completed steps, and response with a concise engineering summary of the implementation status.",
4220 normalized_path, rewrite_count
4221 ));
4222 results.push(outcome);
4223 let _ = tx.send(InferenceEvent::Thought("Frontend rewrite guard: block reached — prompting for task update and summary.".to_string())).await;
4224 break; } else if !frontend_polish_intervention_emitted
4226 && is_frontend_asset
4227 && *rewrite_count >= 2
4228 {
4229 frontend_polish_intervention_emitted = true;
4230 loop_intervention = Some(format!(
4231 "STOP REWRITING. You have already written `{}` {} times. The current version is sufficient as a foundation. \
4232 Do NOT use `write_file` on this file again. Instead, check off your completed steps in `.hematite/TASK.md` and move on to the next file or provide your final summary.",
4233 normalized_path, rewrite_count
4234 ));
4235 results.push(outcome);
4236 let _ = tx.send(InferenceEvent::Thought("Frontend polish guard: repeated rewrite detected; prompting for progress log and next steps.".to_string())).await;
4237 break; }
4239 }
4240 }
4241 }
4242
4243 if !outcome.is_error
4244 && intent.sovereign_mode
4245 && is_scaffold_request(&effective_user_input)
4246 && outcome.latest_target_dir.is_some()
4247 {
4248 sovereign_bootstrap_complete = true;
4249 }
4250 results.push(outcome);
4251 if sovereign_bootstrap_complete {
4252 let _ = tx
4253 .send(InferenceEvent::Thought(
4254 "Sovereign scaffold bootstrap complete: stopping this session after root setup so the resumed session can continue inside the new project."
4255 .to_string(),
4256 ))
4257 .await;
4258 break;
4259 }
4260 }
4261
4262 let mut authoritative_tool_output: Option<String> = None;
4264 let mut blocked_policy_output: Option<String> = None;
4265 let mut recoverable_policy_intervention: Option<String> = None;
4266 let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
4267 let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
4268 None;
4269 for res in results {
4270 let call_id = res.call_id.clone();
4271 let tool_name = res.tool_name.clone();
4272 let final_output = res.output.clone();
4273 let is_error = res.is_error;
4274 for msg in res.msg_results {
4275 self.history.push(msg);
4276 }
4277
4278 if let Some(path) = res.latest_target_dir {
4280 if intent.sovereign_mode && sovereign_task_root.is_none() {
4281 sovereign_task_root = Some(path.clone());
4282 self.pending_teleport_handoff = Some(SovereignTeleportHandoff {
4283 root: path.clone(),
4284 plan: build_sovereign_scaffold_handoff(
4285 &effective_user_input,
4286 &sovereign_scaffold_targets,
4287 ),
4288 });
4289 let _ = tx
4290 .send(InferenceEvent::Thought(format!(
4291 "Sovereign scaffold root established at `{}`; rebinding project context there for the rest of this turn.",
4292 path
4293 )))
4294 .await;
4295 }
4296 self.latest_target_dir = Some(path);
4297 }
4298
4299 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
4300 if let Some(root) = sovereign_task_root.as_ref() {
4301 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4302 let resolved = crate::tools::file_ops::resolve_candidate(path);
4303 let root_path = std::path::Path::new(root);
4304 if let Ok(relative) = resolved.strip_prefix(root_path) {
4305 if !relative.as_os_str().is_empty() {
4306 sovereign_scaffold_targets
4307 .insert(relative.to_string_lossy().replace('\\', "/"));
4308 }
4309 self.pending_teleport_handoff =
4310 Some(SovereignTeleportHandoff {
4311 root: root.clone(),
4312 plan: build_sovereign_scaffold_handoff(
4313 &effective_user_input,
4314 &sovereign_scaffold_targets,
4315 ),
4316 });
4317 }
4318 }
4319 }
4320 }
4321 if matches!(
4322 tool_name.as_str(),
4323 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4324 ) {
4325 mutation_occurred = true;
4326 implementation_started = true;
4327 if !is_error {
4328 if let Some(target) = action_target_path(&tool_name, &res.args) {
4329 turn_mutated_paths.insert(target);
4330 }
4331 }
4332 if !is_error {
4334 let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
4335 if !path.is_empty() {
4336 self.vein.bump_heat(path);
4337 self.l1_context = self.vein.l1_context();
4338 compact_stale_reads(&mut self.history, path);
4341 }
4342 self.refresh_repo_map();
4344 }
4345 }
4346
4347 if !is_error
4348 && matches!(
4349 tool_name.as_str(),
4350 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
4351 )
4352 {
4353 }
4355
4356 if res.plan_drafted_this_turn {
4357 plan_drafted_this_turn = true;
4358 }
4359 if let Some(plan) = res.parsed_plan_handoff.clone() {
4360 self.session_memory.current_plan = Some(plan);
4361 }
4362
4363 if tool_name == "verify_build" {
4364 self.record_session_verification(
4365 !is_error
4366 && (final_output.contains("BUILD OK")
4367 || final_output.contains("BUILD SUCCESS")
4368 || final_output.contains("BUILD OKAY")),
4369 if is_error {
4370 "Explicit verify_build failed."
4371 } else {
4372 "Explicit verify_build passed."
4373 },
4374 );
4375 }
4376
4377 let call_key = format!(
4379 "{}:{}",
4380 tool_name,
4381 serde_json::to_string(&res.args).unwrap_or_default()
4382 );
4383 let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
4384 *repeat_count += 1;
4385
4386 let repeat_guard_exempt =
4389 is_repeat_guard_exempt_tool_call(&tool_name, &res.args);
4390 if *repeat_count >= 2 && !repeat_guard_exempt {
4391 loop_intervention = Some(format!(
4392 "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
4393 Do not call it again. Either answer directly from what you already know, \
4394 use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
4395 or ask the user for clarification.",
4396 tool_name, *repeat_count
4397 ));
4398 let _ = tx
4399 .send(InferenceEvent::Thought(format!(
4400 "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
4401 tool_name, *repeat_count
4402 )))
4403 .await;
4404 }
4405
4406 if *repeat_count >= 3 && !repeat_guard_exempt {
4407 self.emit_runtime_failure(
4408 &tx,
4409 RuntimeFailureClass::ToolLoop,
4410 &format!(
4411 "STRICT: You are stuck in a reasoning loop calling `{}`. \
4412 STOP repeating this call. Switch to grounded filesystem tools \
4413 (like `read_file`, `inspect_lines`, or `edit_file`) instead of \
4414 attempting this workflow again.",
4415 tool_name
4416 ),
4417 )
4418 .await;
4419 return Ok(());
4420 }
4421
4422 if is_error {
4423 consecutive_errors += 1;
4424 } else {
4425 consecutive_errors = 0;
4426 }
4427
4428 if consecutive_errors >= 3 {
4429 loop_intervention = Some(
4430 "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
4431 STOP all tool calls immediately. Analyze why your previous 3 calls failed \
4432 (check for hallucinations or invalid arguments) and ask the user for \
4433 clarification if you cannot proceed.".to_string()
4434 );
4435 }
4436
4437 if consecutive_errors >= 4 {
4438 self.emit_runtime_failure(
4439 &tx,
4440 RuntimeFailureClass::ToolLoop,
4441 "Hard termination: too many consecutive tool errors.",
4442 )
4443 .await;
4444 return Ok(());
4445 }
4446
4447 let _ = tx
4448 .send(InferenceEvent::ToolCallResult {
4449 id: call_id.clone(),
4450 name: tool_name.clone(),
4451 output: final_output.clone(),
4452 is_error,
4453 })
4454 .await;
4455
4456 let repeat_guard_exempt = matches!(
4457 tool_name.as_str(),
4458 "verify_build" | "git_commit" | "git_push"
4459 );
4460 if !repeat_guard_exempt {
4461 completed_tool_cache.insert(
4462 canonical_tool_call_key(&tool_name, &res.args),
4463 CachedToolResult {
4464 tool_name: tool_name.clone(),
4465 },
4466 );
4467 }
4468
4469 let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
4471 self.engine.current_context_length(),
4472 );
4473 let capped = if implement_current_plan {
4474 cap_output(&final_output, 1200)
4475 } else if compact_ctx
4476 && (tool_name == "read_file" || tool_name == "inspect_lines")
4477 {
4478 let limit = 3000usize;
4480 if final_output.len() > limit {
4481 let total_lines = final_output.lines().count();
4482 let mut split_at = limit;
4483 while !final_output.is_char_boundary(split_at) && split_at > 0 {
4484 split_at -= 1;
4485 }
4486 let scratch = write_output_to_scratch(&final_output, &tool_name)
4487 .map(|p| format!(" Full file also saved to '{p}'."))
4488 .unwrap_or_default();
4489 format!(
4490 "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
4491 &final_output[..split_at],
4492 total_lines,
4493 total_lines.saturating_sub(150),
4494 scratch,
4495 )
4496 } else {
4497 final_output.clone()
4498 }
4499 } else {
4500 cap_output_for_tool(&final_output, 8000, &tool_name)
4501 };
4502 self.history.push(ChatMessage::tool_result_for_model(
4503 &call_id,
4504 &tool_name,
4505 &capped,
4506 &self.engine.current_model(),
4507 ));
4508
4509 if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
4510 {
4511 overview_runtime_trace =
4512 Some(summarize_runtime_trace_output(&final_output));
4513 }
4514
4515 if !architecture_overview_mode
4516 && !is_error
4517 && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
4518 || (toolchain_mode && tool_name == "describe_toolchain"))
4519 {
4520 authoritative_tool_output = Some(final_output.clone());
4521 }
4522
4523 if !is_error && tool_name == "read_file" {
4524 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4525 let normalized = normalize_workspace_path(path);
4526 let read_offset =
4527 res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
4528 successful_read_targets.insert(normalized.clone());
4529 successful_read_regions.insert((normalized.clone(), read_offset));
4530 }
4531 }
4532
4533 if !is_error && tool_name == "grep_files" {
4534 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
4535 let normalized = normalize_workspace_path(path);
4536 if final_output.starts_with("No matches for ") {
4537 no_match_grep_targets.insert(normalized);
4538 } else if grep_output_is_high_fanout(&final_output) {
4539 broad_grep_targets.insert(normalized);
4540 } else {
4541 successful_grep_targets.insert(normalized);
4542 }
4543 }
4544 }
4545
4546 if is_error
4547 && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
4548 && (final_output.contains("search string not found")
4549 || final_output.contains("search string is too short")
4550 || final_output.contains("search string matched"))
4551 {
4552 if let Some(target) = action_target_path(&tool_name, &res.args) {
4553 let guidance = if final_output.contains("matched") {
4554 let snippet = read_file_preview_for_retry(&target, 120);
4557 format!(
4558 "EDIT FAILED — search string matched multiple locations in `{target}`. \
4559 You need a longer, more unique search string that includes surrounding context.\n\
4560 Current file content (first 120 lines):\n```\n{snippet}\n```\n\
4561 Retry `{tool_name}` with a search string that is unique in the file."
4562 )
4563 } else {
4564 let snippet = read_file_preview_for_retry(&target, 200);
4567 let normalized = normalize_workspace_path(&target);
4570 {
4571 let mut ag = self.action_grounding.lock().await;
4572 let turn = ag.turn_index;
4573 ag.observed_paths.insert(normalized.clone(), turn);
4574 ag.inspected_paths.insert(normalized, turn);
4575 }
4576 format!(
4577 "EDIT FAILED — search string did not match any text in `{target}`.\n\
4578 The model must have generated text that differs from what is actually in the file \
4579 (wrong whitespace, indentation, or stale content).\n\
4580 Current file content (up to 200 lines shown):\n```\n{snippet}\n```\n\
4581 Find the exact line(s) to change above, copy the text character-for-character \
4582 (preserving indentation), and immediately retry `{tool_name}` \
4583 with that exact text as the search string. Do NOT call read_file again — \
4584 the content is already shown above."
4585 )
4586 };
4587 loop_intervention = Some(guidance);
4588 *repeat_count = 0;
4589 }
4590 }
4591
4592 if is_error
4595 && tool_name == "shell"
4596 && final_output.contains("Use the run_code tool instead")
4597 && loop_intervention.is_none()
4598 {
4599 loop_intervention = Some(
4600 "STOP. Shell was blocked because this is a computation task. \
4601 You MUST use `run_code` now — write the code and run it. \
4602 Do NOT output an error message or give up. \
4603 Call `run_code` with the appropriate language and code to compute the answer. \
4604 If writing Python, pass `language: \"python\"`. \
4605 If writing JavaScript, omit language or pass `language: \"javascript\"`."
4606 .to_string(),
4607 );
4608 }
4609
4610 if is_error
4613 && tool_name == "run_code"
4614 && (final_output.contains("source code could not be parsed")
4615 || final_output.contains("Expected ';'")
4616 || final_output.contains("Expected '}'")
4617 || final_output.contains("is not defined")
4618 && final_output.contains("deno"))
4619 && loop_intervention.is_none()
4620 {
4621 loop_intervention = Some(
4622 "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
4623 code but forgot to pass `language: \"python\"`. \
4624 Retry run_code with `language: \"python\"` and the same code. \
4625 Do NOT fall back to shell. Do NOT give up."
4626 .to_string(),
4627 );
4628 }
4629
4630 if res.blocked_by_policy
4631 && is_mcp_workspace_read_tool(&tool_name)
4632 && recoverable_policy_intervention.is_none()
4633 {
4634 recoverable_policy_intervention = Some(
4635 "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
4636 );
4637 recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
4638 recoverable_policy_checkpoint = Some((
4639 OperatorCheckpointState::BlockedPolicy,
4640 "MCP workspace read blocked; rerouting to built-in file tools."
4641 .to_string(),
4642 ));
4643 } else if res.blocked_by_policy
4644 && implement_current_plan
4645 && is_current_plan_irrelevant_tool(&tool_name)
4646 && recoverable_policy_intervention.is_none()
4647 {
4648 recoverable_policy_intervention = Some(format!(
4649 "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
4650 tool_name
4651 ));
4652 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
4653 recoverable_policy_checkpoint = Some((
4654 OperatorCheckpointState::BlockedPolicy,
4655 format!(
4656 "Current-plan execution blocked unrelated tool `{}`.",
4657 tool_name
4658 ),
4659 ));
4660 } else if res.blocked_by_policy
4661 && implement_current_plan
4662 && final_output.contains("requires recent file evidence")
4663 && recoverable_policy_intervention.is_none()
4664 {
4665 let target = action_target_path(&tool_name, &res.args)
4666 .unwrap_or_else(|| "the target file".to_string());
4667 recoverable_policy_intervention = Some(format!(
4668 "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
4669 ));
4670 recoverable_policy_recipe =
4671 Some(RecoveryScenario::RecentFileEvidenceMissing);
4672 recoverable_policy_checkpoint = Some((
4673 OperatorCheckpointState::BlockedRecentFileEvidence,
4674 format!("Edit blocked on `{target}`; recent file evidence missing."),
4675 ));
4676 } else if res.blocked_by_policy
4677 && implement_current_plan
4678 && final_output.contains("requires an exact local line window first")
4679 && recoverable_policy_intervention.is_none()
4680 {
4681 let target = action_target_path(&tool_name, &res.args)
4682 .unwrap_or_else(|| "the target file".to_string());
4683 recoverable_policy_intervention = Some(format!(
4684 "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
4685 ));
4686 recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
4687 recoverable_policy_checkpoint = Some((
4688 OperatorCheckpointState::BlockedExactLineWindow,
4689 format!("Edit blocked on `{target}`; exact line window required."),
4690 ));
4691 } else if res.blocked_by_policy
4692 && (final_output.contains("Prefer `")
4693 || final_output.contains("Prefer tool"))
4694 && recoverable_policy_intervention.is_none()
4695 {
4696 recoverable_policy_intervention = Some(final_output.clone());
4697 recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
4698 recoverable_policy_checkpoint = Some((
4699 OperatorCheckpointState::BlockedPolicy,
4700 "Action blocked by policy; self-correction triggered using tool recommendation."
4701 .to_string(),
4702 ));
4703 } else if res.blocked_by_policy && blocked_policy_output.is_none() {
4704 blocked_policy_output = Some(final_output.clone());
4705 }
4706
4707 if *repeat_count >= 5 {
4708 let _ = tx.send(InferenceEvent::Done).await;
4709 return Ok(());
4710 }
4711
4712 if implement_current_plan
4713 && !implementation_started
4714 && !is_error
4715 && is_non_mutating_plan_step_tool(&tool_name)
4716 {
4717 non_mutating_plan_steps += 1;
4718 }
4719 }
4720
4721 if sovereign_bootstrap_complete
4722 && intent.sovereign_mode
4723 && is_scaffold_request(&effective_user_input)
4724 {
4725 let response = if let Some(root) = sovereign_task_root.as_deref() {
4726 format!(
4727 "Project root created at `{root}`. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
4728 )
4729 } else {
4730 "Project root created. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
4731 .to_string()
4732 };
4733 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4734 .await;
4735 return Ok(());
4736 }
4737
4738 if let Some(intervention) = recoverable_policy_intervention {
4739 if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
4740 self.emit_operator_checkpoint(&tx, state, summary).await;
4741 }
4742 if let Some(scenario) = recoverable_policy_recipe.take() {
4743 let recipe = plan_recovery(scenario, &self.recovery_context);
4744 self.emit_recovery_recipe_summary(
4745 &tx,
4746 recipe.recipe.scenario.label(),
4747 compact_recovery_plan_summary(&recipe),
4748 )
4749 .await;
4750 }
4751 loop_intervention = Some(intervention);
4752 let _ = tx
4753 .send(InferenceEvent::Thought(
4754 "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
4755 .into(),
4756 ))
4757 .await;
4758 continue;
4759 }
4760
4761 if architecture_overview_mode {
4762 match overview_runtime_trace.as_deref() {
4763 Some(runtime_trace) => {
4764 let response = build_architecture_overview_answer(runtime_trace);
4765 self.history.push(ChatMessage::assistant_text(&response));
4766 self.transcript.log_agent(&response);
4767
4768 for chunk in chunk_text(&response, 8) {
4769 if !chunk.is_empty() {
4770 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4771 }
4772 }
4773
4774 let _ = tx.send(InferenceEvent::Done).await;
4775 break;
4776 }
4777 None => {
4778 loop_intervention = Some(
4779 "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."
4780 .to_string(),
4781 );
4782 continue;
4783 }
4784 }
4785 }
4786
4787 if implement_current_plan
4788 && !implementation_started
4789 && non_mutating_plan_steps >= non_mutating_plan_hard_cap
4790 {
4791 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();
4792 self.history.push(ChatMessage::assistant_text(&msg));
4793 self.transcript.log_agent(&msg);
4794
4795 for chunk in chunk_text(&msg, 8) {
4796 if !chunk.is_empty() {
4797 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4798 }
4799 }
4800
4801 let _ = tx.send(InferenceEvent::Done).await;
4802 break;
4803 }
4804
4805 if let Some(blocked_output) = blocked_policy_output {
4806 self.emit_operator_checkpoint(
4807 &tx,
4808 OperatorCheckpointState::BlockedPolicy,
4809 "A blocked tool path was surfaced directly to the operator.",
4810 )
4811 .await;
4812 self.history
4813 .push(ChatMessage::assistant_text(&blocked_output));
4814 self.transcript.log_agent(&blocked_output);
4815
4816 for chunk in chunk_text(&blocked_output, 8) {
4817 if !chunk.is_empty() {
4818 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4819 }
4820 }
4821
4822 let _ = tx.send(InferenceEvent::Done).await;
4823 break;
4824 }
4825
4826 if let Some(tool_output) = authoritative_tool_output {
4827 self.history.push(ChatMessage::assistant_text(&tool_output));
4828 self.transcript.log_agent(&tool_output);
4829
4830 for chunk in chunk_text(&tool_output, 8) {
4831 if !chunk.is_empty() {
4832 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4833 }
4834 }
4835
4836 let _ = tx.send(InferenceEvent::Done).await;
4837 break;
4838 }
4839
4840 if implement_current_plan && !implementation_started {
4841 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.";
4842 if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
4843 loop_intervention = Some(format!(
4844 "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
4845 base
4846 ));
4847 } else {
4848 loop_intervention = Some(base.to_string());
4849 }
4850 } else if self.workflow_mode == WorkflowMode::Architect {
4851 loop_intervention = Some(
4852 format!(
4853 "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.",
4854 architect_handoff_contract()
4855 ),
4856 );
4857 }
4858
4859 if mutation_occurred && !yolo && !intent.sovereign_mode {
4861 let _ = tx
4862 .send(InferenceEvent::Thought(
4863 "Self-Verification: Running contract-aware workspace verification..."
4864 .into(),
4865 ))
4866 .await;
4867 let verify_outcome = self.auto_verify_workspace(&turn_mutated_paths).await;
4868 let verify_res = verify_outcome.summary;
4869 let verify_ok = verify_outcome.ok;
4870 self.record_verify_build_result(verify_ok, &verify_res)
4871 .await;
4872 self.record_session_verification(
4873 verify_ok,
4874 if verify_ok {
4875 "Automatic workspace verification passed."
4876 } else {
4877 "Automatic workspace verification failed."
4878 },
4879 );
4880 self.history.push(ChatMessage::system(&format!(
4881 "\n# SYSTEM VERIFICATION\n{verify_res}"
4882 )));
4883 let _ = tx
4884 .send(InferenceEvent::Thought(
4885 "Verification turn injected into history.".into(),
4886 ))
4887 .await;
4888 }
4889
4890 continue;
4892 } else if let Some(response_text) = text {
4893 if finish_reason.as_deref() == Some("length") && near_context_ceiling {
4894 if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
4895 let cleaned = build_session_reset_semantics_answer();
4896 self.history.push(ChatMessage::assistant_text(&cleaned));
4897 self.transcript.log_agent(&cleaned);
4898 for chunk in chunk_text(&cleaned, 8) {
4899 if !chunk.is_empty() {
4900 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
4901 }
4902 }
4903 let _ = tx.send(InferenceEvent::Done).await;
4904 break;
4905 }
4906
4907 let warning = format_runtime_failure(
4908 RuntimeFailureClass::ContextWindow,
4909 "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.",
4910 );
4911 self.history.push(ChatMessage::assistant_text(&warning));
4912 self.transcript.log_agent(&warning);
4913 let _ = tx
4914 .send(InferenceEvent::Thought(
4915 "Length recovery: model hit the context ceiling before completing the answer."
4916 .into(),
4917 ))
4918 .await;
4919 for chunk in chunk_text(&warning, 8) {
4920 if !chunk.is_empty() {
4921 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
4922 }
4923 }
4924 let _ = tx.send(InferenceEvent::Done).await;
4925 break;
4926 }
4927
4928 if response_text.contains("<|tool_call")
4929 || response_text.contains("[END_TOOL_REQUEST]")
4930 || response_text.contains("<|tool_response")
4931 || response_text.contains("<tool_response|>")
4932 {
4933 loop_intervention = Some(
4934 "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(),
4935 );
4936 continue;
4937 }
4938
4939 if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
4941 {
4942 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
4943 self.reasoning_history = Some(thought);
4946 }
4947
4948 let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
4950
4951 if implement_current_plan && !implementation_started {
4952 loop_intervention = Some(
4953 "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(),
4954 );
4955 continue;
4956 }
4957
4958 if cleaned.is_empty() {
4964 empty_cleaned_nudges += 1;
4965 if empty_cleaned_nudges == 1 {
4966 loop_intervention = Some(
4967 "Your visible response was empty. The tool already returned data. \
4968 Write your answer now in plain text — no <think> tags, no tool calls. \
4969 State the key facts in 2-5 sentences and stop."
4970 .to_string(),
4971 );
4972 continue;
4973 } else if empty_cleaned_nudges == 2 {
4974 loop_intervention = Some(
4975 "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
4976 Write the answer in plain text right now. \
4977 Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
4978 .to_string(),
4979 );
4980 continue;
4981 }
4982 let class = RuntimeFailureClass::EmptyModelResponse;
4985 self.emit_runtime_failure(
4986 &tx,
4987 class,
4988 "Model returned empty content after 2 nudge attempts.",
4989 )
4990 .await;
4991 break;
4992 }
4993
4994 let architect_handoff = self.persist_architect_handoff(&cleaned);
4995 self.history.push(ChatMessage::assistant_text(&cleaned));
4996 self.transcript.log_agent(&cleaned);
4997 visible_closeout_emitted = true;
4998
4999 for chunk in chunk_text(&cleaned, 8) {
5001 if !chunk.is_empty() {
5002 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
5003 }
5004 }
5005
5006 if let Some(plan) = architect_handoff.as_ref() {
5007 let note = architect_handoff_operator_note(plan);
5008 self.history.push(ChatMessage::system(¬e));
5009 self.transcript.log_system(¬e);
5010 let _ = tx
5011 .send(InferenceEvent::MutedToken(format!("\n{}", note)))
5012 .await;
5013 }
5014
5015 self.emit_done_events(&tx).await;
5016 break;
5017 } else {
5018 let detail = "Model returned an empty response.";
5019 let class = classify_runtime_failure(detail);
5020 if should_retry_runtime_failure(class) {
5021 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
5022 if let RecoveryDecision::Attempt(plan) =
5023 attempt_recovery(scenario, &mut self.recovery_context)
5024 {
5025 self.transcript.log_system(
5026 "Automatic provider recovery triggered: model returned an empty response.",
5027 );
5028 self.emit_recovery_recipe_summary(
5029 &tx,
5030 plan.recipe.scenario.label(),
5031 compact_recovery_plan_summary(&plan),
5032 )
5033 .await;
5034 let _ = tx
5035 .send(InferenceEvent::ProviderStatus {
5036 state: ProviderRuntimeState::Recovering,
5037 summary: compact_runtime_recovery_summary(class).into(),
5038 })
5039 .await;
5040 self.emit_operator_checkpoint(
5041 &tx,
5042 OperatorCheckpointState::RecoveringProvider,
5043 compact_runtime_recovery_summary(class),
5044 )
5045 .await;
5046 continue;
5047 }
5048 }
5049 }
5050
5051 self.emit_runtime_failure(&tx, class, detail).await;
5052 break;
5053 }
5054 }
5055
5056 let task_progress_after = if implement_current_plan {
5057 read_task_checklist_progress()
5058 } else {
5059 None
5060 };
5061
5062 if implement_current_plan
5063 && !visible_closeout_emitted
5064 && should_continue_plan_execution(
5065 current_plan_pass,
5066 task_progress_before,
5067 task_progress_after,
5068 &turn_mutated_paths,
5069 )
5070 {
5071 if let Some(progress) = task_progress_after {
5072 let _ = tx
5073 .send(InferenceEvent::Thought(format!(
5074 "Checklist still has {} unchecked item(s). Continuing autonomous implementation pass {}.",
5075 progress.remaining,
5076 current_plan_pass + 1
5077 )))
5078 .await;
5079 let synthetic_turn = UserTurn {
5080 text: build_continue_plan_execution_prompt(progress),
5081 attached_document: None,
5082 attached_image: None,
5083 };
5084 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
5085 }
5086 }
5087
5088 if implement_current_plan && !visible_closeout_emitted {
5089 let _ = tx.send(InferenceEvent::Thought("Implementation passthrough complete. Requesting final engineering summary (NLG-only mode)...".to_string())).await;
5091
5092 let outstanding_note = task_progress_after
5093 .filter(|progress| progress.has_open_items())
5094 .map(|progress| {
5095 format!(
5096 " `.hematite/TASK.md` still has {} unchecked item(s); explain the concrete blocker or remaining non-optional work.",
5097 progress.remaining
5098 )
5099 })
5100 .unwrap_or_default();
5101 let synthetic_turn = UserTurn {
5102 text: format!(
5103 "Implementation passes complete. YOU ARE NOW IN SUMMARY MODE. STOP calling tools — all tools are hidden. Provide a concise human engineering summary of what you built, what was verified, and whether `.hematite/TASK.md` is fully checked off.{}",
5104 outstanding_note
5105 ),
5106 attached_document: None,
5107 attached_image: None,
5108 };
5109 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), true)).await;
5112 }
5113
5114 if plan_drafted_this_turn
5115 && matches!(
5116 self.workflow_mode,
5117 WorkflowMode::Auto | WorkflowMode::Architect
5118 )
5119 {
5120 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
5121 let _ = tx
5122 .send(InferenceEvent::ApprovalRequired {
5123 id: "plan_approval".to_string(),
5124 name: "plan_authorization".to_string(),
5125 display: "A comprehensive scaffolding blueprint has been drafted to .hematite/PLAN.md. Autonomously execute implementation now?".to_string(),
5126 diff: None,
5127 mutation_label: Some("SYSTEM PLAN AUTHORIZATION".to_string()),
5128 responder: appr_tx,
5129 })
5130 .await;
5131
5132 if let Ok(true) = appr_rx.await {
5133 self.history.clear();
5137 self.running_summary = None;
5138 self.set_workflow_mode(WorkflowMode::Code);
5139
5140 let _ = tx.send(InferenceEvent::ChainImplementPlan).await;
5141
5142 let next_input = implement_current_plan_prompt().to_string();
5143 let synthetic_turn = UserTurn {
5144 text: next_input,
5145 attached_document: None,
5146 attached_image: None,
5147 };
5148 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
5149 }
5150 }
5151
5152 self.trim_history(80);
5153 self.refresh_session_memory();
5154 self.last_goal = Some(user_input.chars().take(300).collect());
5156 self.turn_count = self.turn_count.saturating_add(1);
5157 self.save_session();
5158 self.emit_compaction_pressure(&tx).await;
5159 Ok(())
5160 }
5161
5162 async fn emit_runtime_failure(
5163 &mut self,
5164 tx: &mpsc::Sender<InferenceEvent>,
5165 class: RuntimeFailureClass,
5166 detail: &str,
5167 ) {
5168 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
5169 let decision = preview_recovery_decision(scenario, &self.recovery_context);
5170 self.emit_recovery_recipe_summary(
5171 tx,
5172 scenario.label(),
5173 compact_recovery_decision_summary(&decision),
5174 )
5175 .await;
5176 let needs_refresh = match &decision {
5177 RecoveryDecision::Attempt(plan) => plan
5178 .recipe
5179 .steps
5180 .contains(&RecoveryStep::RefreshRuntimeProfile),
5181 RecoveryDecision::Escalate { recipe, .. } => {
5182 recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
5183 }
5184 };
5185 if needs_refresh {
5186 if let Some((model_id, context_length, changed)) = self
5187 .refresh_runtime_profile_and_report(tx, "context_window_failure")
5188 .await
5189 {
5190 let note = if changed {
5191 format!(
5192 "Runtime refresh after context-window failure: model {} | CTX {}",
5193 model_id, context_length
5194 )
5195 } else {
5196 format!(
5197 "Runtime refresh after context-window failure confirms model {} | CTX {}",
5198 model_id, context_length
5199 )
5200 };
5201 let _ = tx.send(InferenceEvent::Thought(note)).await;
5202 }
5203 }
5204 }
5205 if let Some(state) = provider_state_for_runtime_failure(class) {
5206 let _ = tx
5207 .send(InferenceEvent::ProviderStatus {
5208 state,
5209 summary: compact_runtime_failure_summary(class).into(),
5210 })
5211 .await;
5212 }
5213 if let Some(state) = checkpoint_state_for_runtime_failure(class) {
5214 self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
5215 .await;
5216 }
5217 let formatted = format_runtime_failure(class, detail);
5218 self.history.push(ChatMessage::system(&format!(
5219 "# RUNTIME FAILURE\n{}",
5220 formatted
5221 )));
5222 self.transcript.log_system(&formatted);
5223 let _ = tx.send(InferenceEvent::Error(formatted)).await;
5224 let _ = tx.send(InferenceEvent::Done).await;
5225 }
5226
5227 async fn auto_verify_workspace(
5230 &self,
5231 mutated_paths: &std::collections::BTreeSet<String>,
5232 ) -> AutoVerificationOutcome {
5233 let root = crate::tools::file_ops::workspace_root();
5234 let profile = crate::agent::workspace_profile::load_workspace_profile(&root)
5235 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(&root));
5236
5237 let mut sections = Vec::new();
5238 let mut overall_ok = true;
5239 let contract = profile.runtime_contract.as_ref();
5240 let verification_workflows: Vec<String> = match contract {
5241 Some(contract) if !contract.verification_workflows.is_empty() => {
5242 contract.verification_workflows.clone()
5243 }
5244 _ if profile.build_hint.is_some() || profile.verify_profile.is_some() => {
5245 vec!["build".to_string()]
5246 }
5247 _ => Vec::new(),
5248 };
5249
5250 for workflow in verification_workflows {
5251 if !should_run_contract_verification_workflow(contract, &workflow, mutated_paths) {
5252 continue;
5253 }
5254 let outcome = self.auto_run_verification_workflow(&workflow).await;
5255 overall_ok &= outcome.ok;
5256 sections.push(outcome.summary);
5257 }
5258
5259 if sections.is_empty() {
5260 sections.push(
5261 "[verify]\nVERIFICATION SKIPPED: Workspace profile does not define an automatic verification workflow for this stack."
5262 .to_string(),
5263 );
5264 }
5265
5266 let header = if overall_ok {
5267 "WORKSPACE VERIFICATION SUCCESS: Automatic validation passed."
5268 } else {
5269 "WORKSPACE VERIFICATION FAILURE: Automatic validation found problems."
5270 };
5271
5272 AutoVerificationOutcome {
5273 ok: overall_ok,
5274 summary: format!("{}\n\n{}", header, sections.join("\n\n")),
5275 }
5276 }
5277
5278 async fn auto_run_verification_workflow(&self, workflow: &str) -> AutoVerificationOutcome {
5279 match workflow {
5280 "build" | "test" | "lint" | "fix" => {
5281 match crate::tools::verify_build::execute(
5282 &serde_json::json!({ "action": workflow }),
5283 )
5284 .await
5285 {
5286 Ok(out) => AutoVerificationOutcome {
5287 ok: true,
5288 summary: format!(
5289 "[{}]\n{} SUCCESS: Automatic {} verification passed.\n\n{}",
5290 workflow,
5291 workflow.to_ascii_uppercase(),
5292 workflow,
5293 cap_output(&out, 2000)
5294 ),
5295 },
5296 Err(e) => AutoVerificationOutcome {
5297 ok: false,
5298 summary: format!(
5299 "[{}]\n{} FAILURE: Automatic {} verification failed.\n\n{}",
5300 workflow,
5301 workflow.to_ascii_uppercase(),
5302 workflow,
5303 cap_output(&e, 2000)
5304 ),
5305 },
5306 }
5307 }
5308 other => {
5309 let args = serde_json::json!({ "workflow": other });
5311 match crate::tools::workspace_workflow::run_workspace_workflow(&args).await {
5312 Ok(out) => {
5313 let ok = !out.contains("Result: FAIL") && !out.contains("Error:");
5316 AutoVerificationOutcome {
5317 ok,
5318 summary: format!("[{}]\n{}", other, out.trim()),
5319 }
5320 }
5321 Err(e) => {
5322 let needs_boot = e.contains("No tracked website server labeled")
5326 || e.contains("HTTP probe failed")
5327 || e.contains("Connection refused")
5328 || e.contains("error trying to connect");
5329
5330 if other == "website_validate" && needs_boot {
5331 let start_args = serde_json::json!({ "workflow": "website_start" });
5332 if let Ok(_) = crate::tools::workspace_workflow::run_workspace_workflow(
5333 &start_args,
5334 )
5335 .await
5336 {
5337 if let Ok(retry_out) =
5338 crate::tools::workspace_workflow::run_workspace_workflow(&args)
5339 .await
5340 {
5341 let ok = !retry_out.contains("Result: FAIL")
5342 && !retry_out.contains("Error:");
5343 return AutoVerificationOutcome {
5344 ok,
5345 summary: format!(
5346 "[{}]\n(Auto-booted) {}",
5347 other,
5348 retry_out.trim()
5349 ),
5350 };
5351 }
5352 }
5353 }
5354
5355 AutoVerificationOutcome {
5356 ok: false,
5357 summary: format!("[{}]\nVERIFICATION FAILURE: {}", other, e),
5358 }
5359 }
5360 }
5361 }
5362 }
5363 }
5364
5365 async fn compact_history_if_needed(
5369 &mut self,
5370 tx: &mpsc::Sender<InferenceEvent>,
5371 anchor_index: Option<usize>,
5372 ) -> Result<bool, String> {
5373 let vram_ratio = self.gpu_state.ratio();
5374 let context_length = self.engine.current_context_length();
5375 let config = CompactionConfig::adaptive(context_length, vram_ratio);
5376
5377 if !compaction::should_compact(&self.history, context_length, vram_ratio) {
5378 return Ok(false);
5379 }
5380
5381 let _ = tx
5382 .send(InferenceEvent::Thought(format!(
5383 "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
5384 context_length / 1000,
5385 vram_ratio * 100.0,
5386 config.max_estimated_tokens / 1000,
5387 )))
5388 .await;
5389
5390 let result = compaction::compact_history(
5391 &self.history,
5392 self.running_summary.as_deref(),
5393 config,
5394 anchor_index,
5395 );
5396
5397 let removed_message_count = self.history.len().saturating_sub(result.messages.len());
5398 self.history = result.messages;
5399 self.running_summary = result.summary;
5400
5401 let previous_memory = self.session_memory.clone();
5403 self.session_memory = compaction::extract_memory(&self.history);
5404 self.session_memory
5405 .inherit_runtime_ledger_from(&previous_memory);
5406 self.session_memory.record_compaction(
5407 removed_message_count,
5408 format!(
5409 "Compacted history around active task '{}' and preserved {} working-set file(s).",
5410 self.session_memory.current_task,
5411 self.session_memory.working_set.len()
5412 ),
5413 );
5414 self.emit_compaction_pressure(tx).await;
5415
5416 let first_non_sys = self
5419 .history
5420 .iter()
5421 .position(|m| m.role != "system")
5422 .unwrap_or(self.history.len());
5423 if first_non_sys < self.history.len() {
5424 if let Some(user_offset) = self.history[first_non_sys..]
5425 .iter()
5426 .position(|m| m.role == "user")
5427 {
5428 if user_offset > 0 {
5429 self.history
5430 .drain(first_non_sys..first_non_sys + user_offset);
5431 }
5432 }
5433 }
5434
5435 let _ = tx
5436 .send(InferenceEvent::Thought(format!(
5437 "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
5438 self.session_memory.current_task,
5439 self.session_memory.working_set.len()
5440 )))
5441 .await;
5442 let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
5443 self.emit_recovery_recipe_summary(
5444 tx,
5445 recipe.recipe.scenario.label(),
5446 compact_recovery_plan_summary(&recipe),
5447 )
5448 .await;
5449 self.emit_operator_checkpoint(
5450 tx,
5451 OperatorCheckpointState::HistoryCompacted,
5452 format!(
5453 "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
5454 self.session_memory.current_task,
5455 self.session_memory.working_set.len()
5456 ),
5457 )
5458 .await;
5459
5460 Ok(true)
5461 }
5462
5463 fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
5467 if query.trim().split_whitespace().count() < 3 {
5469 return None;
5470 }
5471
5472 let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
5473 if results.is_empty() {
5474 return None;
5475 }
5476
5477 let semantic_active = self.vein.has_any_embeddings();
5478 let header = if semantic_active {
5479 "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
5480 Use this to answer without needing extra read_file calls where possible.\n\n"
5481 } else {
5482 "# Relevant context from The Vein (BM25 keyword retrieval)\n\
5483 Use this to answer without needing extra read_file calls where possible.\n\n"
5484 };
5485
5486 let mut ctx = String::from(header);
5487 let mut paths: Vec<String> = Vec::new();
5488
5489 let mut total = 0usize;
5490 const MAX_CTX_CHARS: usize = 1_500;
5491
5492 for r in results {
5493 if total >= MAX_CTX_CHARS {
5494 break;
5495 }
5496 let snippet = if r.content.len() > 500 {
5497 format!("{}...", &r.content[..500])
5498 } else {
5499 r.content.clone()
5500 };
5501 ctx.push_str(&format!("--- {} ---\n{}\n\n", r.path, snippet));
5502 total += snippet.len() + r.path.len() + 10;
5503 if !paths.contains(&r.path) {
5504 paths.push(r.path);
5505 }
5506 }
5507
5508 Some((ctx, paths))
5509 }
5510
5511 fn context_window_slice(&self) -> Vec<ChatMessage> {
5514 let mut result = Vec::new();
5515
5516 if self.history.len() > 1 {
5518 for m in &self.history[1..] {
5519 if m.role == "system" {
5520 continue;
5521 }
5522
5523 let mut sanitized = m.clone();
5524 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
5526 sanitized.content = MessageContent::Text(" ".into());
5527 }
5528 result.push(sanitized);
5529 }
5530 }
5531
5532 if !result.is_empty() && result[0].role != "user" {
5535 result.insert(0, ChatMessage::user("Continuing previous context..."));
5536 }
5537
5538 result
5539 }
5540
5541 fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
5542 let mut result = Vec::new();
5543
5544 if self.history.len() > 1 {
5545 let start = start_idx.max(1).min(self.history.len());
5546 for m in &self.history[start..] {
5547 if m.role == "system" {
5548 continue;
5549 }
5550
5551 let mut sanitized = m.clone();
5552 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
5553 sanitized.content = MessageContent::Text(" ".into());
5554 }
5555 result.push(sanitized);
5556 }
5557 }
5558
5559 if !result.is_empty() && result[0].role != "user" {
5560 result.insert(0, ChatMessage::user("Continuing current plan execution..."));
5561 }
5562
5563 result
5564 }
5565
5566 fn trim_history(&mut self, max_messages: usize) {
5568 if self.history.len() <= max_messages {
5569 return;
5570 }
5571 let excess = self.history.len() - max_messages;
5573 self.history.drain(1..=excess);
5574 }
5575
5576 async fn repair_tool_args(
5578 &self,
5579 tool_name: &str,
5580 bad_json: &str,
5581 tx: &mpsc::Sender<InferenceEvent>,
5582 ) -> Result<Value, String> {
5583 let _ = tx
5584 .send(InferenceEvent::Thought(format!(
5585 "Attempting to repair malformed JSON for '{}'...",
5586 tool_name
5587 )))
5588 .await;
5589
5590 let prompt = format!(
5591 "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.",
5592 tool_name, bad_json
5593 );
5594
5595 let messages = vec![
5596 ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
5597 ChatMessage::user(&prompt),
5598 ];
5599
5600 let (text, _, _, _) = self
5602 .engine
5603 .call_with_tools(&messages, &[], self.fast_model.as_deref())
5604 .await
5605 .map_err(|e| e.to_string())?;
5606
5607 let cleaned = text
5608 .unwrap_or_default()
5609 .trim()
5610 .trim_start_matches("```json")
5611 .trim_start_matches("```")
5612 .trim_end_matches("```")
5613 .trim()
5614 .to_string();
5615
5616 serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
5617 }
5618
5619 async fn run_critic_check(
5621 &self,
5622 path: &str,
5623 content: &str,
5624 tx: &mpsc::Sender<InferenceEvent>,
5625 ) -> Option<String> {
5626 let ext = std::path::Path::new(path)
5628 .extension()
5629 .and_then(|e| e.to_str())
5630 .unwrap_or("");
5631 const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
5632 if !CRITIC_EXTS.contains(&ext) {
5633 return None;
5634 }
5635
5636 let _ = tx
5637 .send(InferenceEvent::Thought(format!(
5638 "CRITIC: Reviewing changes to '{}'...",
5639 path
5640 )))
5641 .await;
5642
5643 let truncated = cap_output(content, 4000);
5644
5645 const WEB_EXTS_CRITIC: &[&str] = &[
5646 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
5647 ];
5648 let is_web_file = WEB_EXTS_CRITIC.contains(&ext);
5649
5650 let prompt = if is_web_file {
5651 format!(
5652 "You are a senior web developer doing a quality review of '{}'. \
5653 Identify ONLY real problems — missing, broken, or incomplete things that would \
5654 make this file not work or look bad in production. Check:\n\
5655 - HTML: missing DOCTYPE/charset/title/viewport meta, broken links, missing aria, unsemantic structure\n\
5656 - CSS: hardcoded px instead of responsive units, missing mobile media queries, class names used in HTML but not defined here\n\
5657 - JS/TS: missing error handling, undefined variables, console.log left in, DOM elements referenced that may not exist\n\
5658 - All: placeholder text/colors/lorem-ipsum left in, TODO comments, empty sections\n\
5659 Be extremely concise. List issues as short bullets. If everything is production-ready, output 'PASS'.\n\n\
5660 ```{}\n{}\n```",
5661 path, ext, truncated
5662 )
5663 } else {
5664 format!(
5665 "You are a Senior Security and Code Quality auditor. Review this file content for '{}' \
5666 and identify any critical logic errors, security vulnerabilities, or missing error handling. \
5667 Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
5668 path, ext, truncated
5669 )
5670 };
5671
5672 let messages = vec![
5673 ChatMessage::system("You are a technical critic. Identify ONLY real issues that need fixing. Output 'PASS' if none found."),
5674 ChatMessage::user(&prompt)
5675 ];
5676
5677 let (text, _, _, _) = self
5678 .engine
5679 .call_with_tools(&messages, &[], self.fast_model.as_deref())
5680 .await
5681 .ok()?;
5682
5683 let critique = text?.trim().to_string();
5684 if critique.to_uppercase().contains("PASS") || critique.is_empty() {
5685 None
5686 } else {
5687 Some(critique)
5688 }
5689 }
5690}
5691
5692pub async fn dispatch_tool(
5695 name: &str,
5696 args: &Value,
5697 config: &crate::agent::config::HematiteConfig,
5698) -> Result<String, String> {
5699 dispatch_builtin_tool(name, args, config).await
5700}
5701
5702fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
5703 let trimmed = text.trim();
5704 let stripped = trimmed
5705 .strip_prefix("/think")
5706 .or_else(|| trimmed.strip_prefix("/no_think"))
5707 .map(str::trim)
5708 .unwrap_or(trimmed)
5709 .trim_start_matches('\n')
5710 .trim();
5711 (!stripped.is_empty()).then(|| stripped.to_string())
5712}
5713
5714fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
5715 if tool_name != "inspect_host" {
5716 return;
5717 }
5718
5719 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5720 return;
5721 };
5722 if topic != "fix_plan" {
5723 return;
5724 }
5725
5726 let issue_missing = args
5727 .get("issue")
5728 .and_then(|v| v.as_str())
5729 .map(str::trim)
5730 .is_none_or(|value| value.is_empty());
5731 if !issue_missing {
5732 return;
5733 }
5734
5735 let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
5736 return;
5737 };
5738
5739 let Value::Object(map) = args else {
5740 return;
5741 };
5742 map.insert(
5743 "issue".to_string(),
5744 Value::String(fallback_issue.to_string()),
5745 );
5746}
5747
5748fn fill_missing_dns_lookup_name(
5749 tool_name: &str,
5750 args: &mut Value,
5751 latest_user_prompt: Option<&str>,
5752) {
5753 if tool_name != "inspect_host" {
5754 return;
5755 }
5756
5757 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5758 return;
5759 };
5760 if topic != "dns_lookup" {
5761 return;
5762 }
5763
5764 let name_missing = args
5765 .get("name")
5766 .and_then(|v| v.as_str())
5767 .map(str::trim)
5768 .is_none_or(|value| value.is_empty());
5769 if !name_missing {
5770 return;
5771 }
5772
5773 let Some(prompt) = latest_user_prompt else {
5774 return;
5775 };
5776 let Some(name) = extract_dns_lookup_target_from_text(prompt) else {
5777 return;
5778 };
5779
5780 let Value::Object(map) = args else {
5781 return;
5782 };
5783 map.insert("name".to_string(), Value::String(name));
5784}
5785
5786fn fill_missing_dns_lookup_type(
5787 tool_name: &str,
5788 args: &mut Value,
5789 latest_user_prompt: Option<&str>,
5790) {
5791 if tool_name != "inspect_host" {
5792 return;
5793 }
5794
5795 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5796 return;
5797 };
5798 if topic != "dns_lookup" {
5799 return;
5800 }
5801
5802 let type_missing = args
5803 .get("type")
5804 .and_then(|v| v.as_str())
5805 .map(str::trim)
5806 .is_none_or(|value| value.is_empty());
5807 if !type_missing {
5808 return;
5809 }
5810
5811 let record_type = latest_user_prompt
5812 .and_then(extract_dns_record_type_from_text)
5813 .unwrap_or("A");
5814
5815 let Value::Object(map) = args else {
5816 return;
5817 };
5818 map.insert("type".to_string(), Value::String(record_type.to_string()));
5819}
5820
5821fn fill_missing_event_query_args(
5822 tool_name: &str,
5823 args: &mut Value,
5824 latest_user_prompt: Option<&str>,
5825) {
5826 if tool_name != "inspect_host" {
5827 return;
5828 }
5829
5830 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
5831 return;
5832 };
5833 if topic != "event_query" {
5834 return;
5835 }
5836
5837 let Some(prompt) = latest_user_prompt else {
5838 return;
5839 };
5840
5841 let Value::Object(map) = args else {
5842 return;
5843 };
5844
5845 let event_id_missing = map.get("event_id").and_then(|v| v.as_u64()).is_none();
5846 if event_id_missing {
5847 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
5848 map.insert(
5849 "event_id".to_string(),
5850 Value::Number(serde_json::Number::from(event_id)),
5851 );
5852 }
5853 }
5854
5855 let log_missing = map
5856 .get("log")
5857 .and_then(|v| v.as_str())
5858 .map(str::trim)
5859 .is_none_or(|value| value.is_empty());
5860 if log_missing {
5861 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
5862 map.insert("log".to_string(), Value::String(log_name.to_string()));
5863 }
5864 }
5865
5866 let level_missing = map
5867 .get("level")
5868 .and_then(|v| v.as_str())
5869 .map(str::trim)
5870 .is_none_or(|value| value.is_empty());
5871 if level_missing {
5872 if let Some(level) = extract_event_query_level_from_text(prompt) {
5873 map.insert("level".to_string(), Value::String(level.to_string()));
5874 }
5875 }
5876
5877 let hours_missing = map.get("hours").and_then(|v| v.as_u64()).is_none();
5878 if hours_missing {
5879 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
5880 map.insert(
5881 "hours".to_string(),
5882 Value::Number(serde_json::Number::from(hours)),
5883 );
5884 }
5885 }
5886}
5887
5888fn should_rewrite_shell_to_fix_plan(
5889 tool_name: &str,
5890 args: &Value,
5891 latest_user_prompt: Option<&str>,
5892) -> bool {
5893 if tool_name != "shell" {
5894 return false;
5895 }
5896 let Some(prompt) = latest_user_prompt else {
5897 return false;
5898 };
5899 if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
5900 return false;
5901 }
5902 let command = args
5903 .get("command")
5904 .and_then(|value| value.as_str())
5905 .unwrap_or("");
5906 shell_looks_like_structured_host_inspection(command)
5907}
5908
5909fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
5910 let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(flag));
5911 let regex = regex::Regex::new(&pattern).ok()?;
5912 let captures = regex.captures(command)?;
5913 captures.get(1).map(|m| m.as_str().to_string())
5914}
5915
5916fn clean_shell_dns_token(token: &str) -> String {
5917 token
5918 .trim_matches(|c: char| {
5919 c.is_whitespace()
5920 || matches!(
5921 c,
5922 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
5923 )
5924 })
5925 .trim_end_matches(|c: char| matches!(c, ':' | '.'))
5926 .to_string()
5927}
5928
5929fn looks_like_dns_target(token: &str) -> bool {
5930 let cleaned = clean_shell_dns_token(token);
5931 if cleaned.is_empty() {
5932 return false;
5933 }
5934
5935 let lower = cleaned.to_ascii_lowercase();
5936 if matches!(
5937 lower.as_str(),
5938 "a" | "aaaa"
5939 | "mx"
5940 | "srv"
5941 | "txt"
5942 | "cname"
5943 | "ptr"
5944 | "soa"
5945 | "any"
5946 | "resolve-dnsname"
5947 | "nslookup"
5948 | "host"
5949 | "dig"
5950 | "powershell"
5951 | "-command"
5952 | "foreach-object"
5953 | "select-object"
5954 | "address"
5955 | "ipaddress"
5956 | "name"
5957 | "type"
5958 ) {
5959 return false;
5960 }
5961
5962 if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
5963 return true;
5964 }
5965
5966 cleaned.contains('.')
5967 && cleaned
5968 .chars()
5969 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
5970}
5971
5972fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
5973 for pattern in [
5974 r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#,
5975 r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#,
5976 r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
5977 ] {
5978 let regex = regex::Regex::new(pattern).ok()?;
5979 if let Some(value) = regex
5980 .captures(command)
5981 .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
5982 .filter(|value| looks_like_dns_target(value))
5983 {
5984 return Some(value);
5985 }
5986 }
5987
5988 let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
5989 for captures in quoted.captures_iter(command) {
5990 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
5991 if looks_like_dns_target(&candidate) {
5992 return Some(candidate);
5993 }
5994 }
5995
5996 command
5997 .split_whitespace()
5998 .map(clean_shell_dns_token)
5999 .find(|token| looks_like_dns_target(token))
6000}
6001
6002fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
6003 let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
6004 for captures in quoted.captures_iter(text) {
6005 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
6006 if looks_like_dns_target(&candidate) {
6007 return Some(candidate);
6008 }
6009 }
6010
6011 text.split_whitespace()
6012 .map(clean_shell_dns_token)
6013 .find(|token| looks_like_dns_target(token))
6014}
6015
6016fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
6017 let lower = text.to_ascii_lowercase();
6018 if lower.contains("aaaa record") || lower.contains("ipv6 address") {
6019 Some("AAAA")
6020 } else if lower.contains("mx record") {
6021 Some("MX")
6022 } else if lower.contains("srv record") {
6023 Some("SRV")
6024 } else if lower.contains("txt record") {
6025 Some("TXT")
6026 } else if lower.contains("cname record") {
6027 Some("CNAME")
6028 } else if lower.contains("soa record") {
6029 Some("SOA")
6030 } else if lower.contains("ptr record") {
6031 Some("PTR")
6032 } else if lower.contains("a record")
6033 || (lower.contains("ip address") && lower.contains(" of "))
6034 || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
6035 {
6036 Some("A")
6037 } else {
6038 None
6039 }
6040}
6041
6042fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
6043 let re = regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").ok()?;
6044 re.captures(text)
6045 .and_then(|captures| captures.get(1))
6046 .and_then(|m| m.as_str().parse::<u32>().ok())
6047}
6048
6049fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
6050 let lower = text.to_ascii_lowercase();
6051 if lower.contains("security log") {
6052 Some("Security")
6053 } else if lower.contains("application log") {
6054 Some("Application")
6055 } else if lower.contains("system log") || lower.contains("system errors") {
6056 Some("System")
6057 } else if lower.contains("setup log") {
6058 Some("Setup")
6059 } else {
6060 None
6061 }
6062}
6063
6064fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
6065 let lower = text.to_ascii_lowercase();
6066 if lower.contains("critical") {
6067 Some("Critical")
6068 } else if lower.contains("error") || lower.contains("errors") {
6069 Some("Error")
6070 } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
6071 Some("Warning")
6072 } else if lower.contains("information")
6073 || lower.contains("informational")
6074 || lower.contains("info")
6075 {
6076 Some("Information")
6077 } else {
6078 None
6079 }
6080}
6081
6082fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
6083 let lower = text.to_ascii_lowercase();
6084 let re = regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b").ok()?;
6085 if let Some(hours) = re
6086 .captures(&lower)
6087 .and_then(|captures| captures.get(1))
6088 .and_then(|m| m.as_str().parse::<u32>().ok())
6089 {
6090 return Some(hours);
6091 }
6092 if lower.contains("last hour") || lower.contains("past hour") {
6093 Some(1)
6094 } else if lower.contains("today") {
6095 Some(24)
6096 } else {
6097 None
6098 }
6099}
6100
6101fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
6102 let lower = command.to_ascii_lowercase();
6103 if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
6104 Some("AAAA")
6105 } else if lower.contains("-type mx") || lower.contains("-type=mx") {
6106 Some("MX")
6107 } else if lower.contains("-type srv") || lower.contains("-type=srv") {
6108 Some("SRV")
6109 } else if lower.contains("-type txt") || lower.contains("-type=txt") {
6110 Some("TXT")
6111 } else if lower.contains("-type cname") || lower.contains("-type=cname") {
6112 Some("CNAME")
6113 } else if lower.contains("-type soa") || lower.contains("-type=soa") {
6114 Some("SOA")
6115 } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
6116 Some("PTR")
6117 } else if lower.contains("-type a") || lower.contains("-type=a") {
6118 Some("A")
6119 } else {
6120 extract_dns_record_type_from_text(command)
6121 }
6122}
6123
6124fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
6125 let mut args = serde_json::json!({ "topic": topic });
6126 if topic == "dns_lookup" {
6127 if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
6128 args.as_object_mut()
6129 .unwrap()
6130 .insert("name".to_string(), Value::String(name));
6131 }
6132 let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
6133 args.as_object_mut()
6134 .unwrap()
6135 .insert("type".to_string(), Value::String(record_type.to_string()));
6136 } else if topic == "event_query" {
6137 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
6138 args.as_object_mut().unwrap().insert(
6139 "event_id".to_string(),
6140 Value::Number(serde_json::Number::from(event_id)),
6141 );
6142 }
6143 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
6144 args.as_object_mut()
6145 .unwrap()
6146 .insert("log".to_string(), Value::String(log_name.to_string()));
6147 }
6148 if let Some(level) = extract_event_query_level_from_text(prompt) {
6149 args.as_object_mut()
6150 .unwrap()
6151 .insert("level".to_string(), Value::String(level.to_string()));
6152 }
6153 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
6154 args.as_object_mut().unwrap().insert(
6155 "hours".to_string(),
6156 Value::Number(serde_json::Number::from(hours)),
6157 );
6158 }
6159 }
6160 args
6161}
6162
6163fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
6164 let workflow = preferred_maintainer_workflow(prompt)?;
6165 let lower = prompt.to_ascii_lowercase();
6166 match workflow {
6167 "clean" => Some(serde_json::json!({
6168 "workflow": "clean",
6169 "deep": lower.contains("deep clean")
6170 || lower.contains("deep cleanup")
6171 || lower.contains("deep"),
6172 "reset": lower.contains("reset"),
6173 "prune_dist": lower.contains("prune dist")
6174 || lower.contains("prune old dist")
6175 || lower.contains("prune old artifacts")
6176 || lower.contains("old dist artifacts")
6177 || lower.contains("old artifacts"),
6178 })),
6179 "package_windows" => Some(serde_json::json!({
6180 "workflow": "package_windows",
6181 "installer": lower.contains("installer") || lower.contains("setup.exe"),
6182 "add_to_path": lower.contains("addtopath")
6183 || lower.contains("add to path")
6184 || lower.contains("update path")
6185 || lower.contains("refresh path"),
6186 })),
6187 "release" => {
6188 let version = regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#)
6189 .ok()
6190 .and_then(|re| re.captures(prompt))
6191 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
6192 let bump = if lower.contains("patch") {
6193 Some("patch")
6194 } else if lower.contains("minor") {
6195 Some("minor")
6196 } else if lower.contains("major") {
6197 Some("major")
6198 } else {
6199 None
6200 };
6201 let mut args = serde_json::json!({
6202 "workflow": "release",
6203 "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
6204 "add_to_path": lower.contains("addtopath")
6205 || lower.contains("add to path")
6206 || lower.contains("update path"),
6207 "skip_installer": lower.contains("skip installer"),
6208 "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
6209 "publish_voice_crate": lower.contains("publish voice crate")
6210 || lower.contains("publish hematite-kokoros"),
6211 });
6212 if let Some(version) = version {
6213 args["version"] = Value::String(version);
6214 }
6215 if let Some(bump) = bump {
6216 args["bump"] = Value::String(bump.to_string());
6217 }
6218 Some(args)
6219 }
6220 _ => None,
6221 }
6222}
6223
6224fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
6225 if is_scaffold_request(prompt) {
6226 return None;
6227 }
6228 let workflow = preferred_workspace_workflow(prompt)?;
6229 let lower = prompt.to_ascii_lowercase();
6230 let trimmed = prompt.trim();
6231
6232 if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
6233 return Some(serde_json::json!({
6234 "workflow": "command",
6235 "command": command,
6236 }));
6237 }
6238
6239 if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
6240 return Some(serde_json::json!({
6241 "workflow": "script_path",
6242 "path": path,
6243 }));
6244 }
6245
6246 match workflow {
6247 "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
6248 "workflow": workflow,
6249 })),
6250 "script" => {
6251 let package_script = if lower.contains("npm run ") {
6252 extract_word_after(&lower, "npm run ")
6253 } else if lower.contains("pnpm run ") {
6254 extract_word_after(&lower, "pnpm run ")
6255 } else if lower.contains("bun run ") {
6256 extract_word_after(&lower, "bun run ")
6257 } else if lower.contains("yarn ") {
6258 extract_word_after(&lower, "yarn ")
6259 } else {
6260 None
6261 };
6262
6263 if let Some(name) = package_script {
6264 return Some(serde_json::json!({
6265 "workflow": "package_script",
6266 "name": name,
6267 }));
6268 }
6269
6270 if let Some(name) = extract_word_after(&lower, "just ") {
6271 return Some(serde_json::json!({
6272 "workflow": "just",
6273 "name": name,
6274 }));
6275 }
6276 if let Some(name) = extract_word_after(&lower, "make ") {
6277 return Some(serde_json::json!({
6278 "workflow": "make",
6279 "name": name,
6280 }));
6281 }
6282 if let Some(name) = extract_word_after(&lower, "task ") {
6283 return Some(serde_json::json!({
6284 "workflow": "task",
6285 "name": name,
6286 }));
6287 }
6288
6289 None
6290 }
6291 _ => None,
6292 }
6293}
6294
6295fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
6296 let lower = prompt.to_ascii_lowercase();
6297 for prefix in [
6298 "cargo ",
6299 "npm ",
6300 "pnpm ",
6301 "yarn ",
6302 "bun ",
6303 "pytest",
6304 "go build",
6305 "go test",
6306 "make ",
6307 "just ",
6308 "task ",
6309 "./gradlew",
6310 ".\\gradlew",
6311 ] {
6312 if let Some(index) = lower.find(prefix) {
6313 return Some(prompt[index..].trim().trim_matches('`').to_string());
6314 }
6315 }
6316 None
6317}
6318
6319fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
6320 let normalized = prompt.replace('\\', "/");
6321 for token in normalized.split_whitespace() {
6322 let candidate = token
6323 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
6324 .trim_start_matches("./");
6325 if candidate.starts_with("scripts/")
6326 && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
6327 .iter()
6328 .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
6329 {
6330 return Some(candidate.to_string());
6331 }
6332 }
6333 None
6334}
6335
6336fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
6337 let start = haystack.find(prefix)? + prefix.len();
6338 let tail = &haystack[start..];
6339 let word = tail
6340 .split_whitespace()
6341 .next()
6342 .map(str::trim)
6343 .filter(|value| !value.is_empty())?;
6344 Some(
6345 word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
6346 .to_string(),
6347 )
6348}
6349
6350fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
6351 let lower = command.to_ascii_lowercase();
6352 if lower.contains("clean.ps1") {
6353 return Some(serde_json::json!({
6354 "workflow": "clean",
6355 "deep": lower.contains("-deep"),
6356 "reset": lower.contains("-reset"),
6357 "prune_dist": lower.contains("-prunedist"),
6358 }));
6359 }
6360 if lower.contains("package-windows.ps1") {
6361 return Some(serde_json::json!({
6362 "workflow": "package_windows",
6363 "installer": lower.contains("-installer"),
6364 "add_to_path": lower.contains("-addtopath"),
6365 }));
6366 }
6367 if lower.contains("release.ps1") {
6368 let version = extract_release_arg(command, "-Version");
6369 let bump = extract_release_arg(command, "-Bump");
6370 if version.is_none() && bump.is_none() {
6371 return Some(serde_json::json!({
6372 "workflow": "release"
6373 }));
6374 }
6375 let mut args = serde_json::json!({
6376 "workflow": "release",
6377 "push": lower.contains("-push"),
6378 "add_to_path": lower.contains("-addtopath"),
6379 "skip_installer": lower.contains("-skipinstaller"),
6380 "publish_crates": lower.contains("-publishcrates"),
6381 "publish_voice_crate": lower.contains("-publishvoicecrate"),
6382 });
6383 if let Some(version) = version {
6384 args["version"] = Value::String(version);
6385 }
6386 if let Some(bump) = bump {
6387 args["bump"] = Value::String(bump);
6388 }
6389 return Some(args);
6390 }
6391 None
6392}
6393
6394fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
6395 let lower = command.to_ascii_lowercase();
6396 if lower.contains("clean.ps1")
6397 || lower.contains("package-windows.ps1")
6398 || lower.contains("release.ps1")
6399 {
6400 return None;
6401 }
6402
6403 if let Some(path) = extract_workspace_script_path_from_prompt(command) {
6404 return Some(serde_json::json!({
6405 "workflow": "script_path",
6406 "path": path,
6407 }));
6408 }
6409
6410 let looks_like_workspace_command = [
6411 "cargo ",
6412 "npm ",
6413 "pnpm ",
6414 "yarn ",
6415 "bun ",
6416 "pytest",
6417 "go build",
6418 "go test",
6419 "make ",
6420 "just ",
6421 "task ",
6422 "./gradlew",
6423 ".\\gradlew",
6424 ]
6425 .iter()
6426 .any(|needle| lower.contains(needle));
6427
6428 if looks_like_workspace_command {
6429 Some(serde_json::json!({
6430 "workflow": "command",
6431 "command": command.trim(),
6432 }))
6433 } else {
6434 None
6435 }
6436}
6437
6438fn rewrite_host_tool_call(
6439 tool_name: &mut String,
6440 args: &mut Value,
6441 latest_user_prompt: Option<&str>,
6442) {
6443 if *tool_name == "shell" {
6444 let command = args
6445 .get("command")
6446 .and_then(|value| value.as_str())
6447 .unwrap_or("");
6448 if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
6449 *tool_name = "run_hematite_maintainer_workflow".to_string();
6450 *args = maintainer_workflow_args;
6451 return;
6452 }
6453 if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
6454 *tool_name = "run_workspace_workflow".to_string();
6455 *args = workspace_workflow_args;
6456 return;
6457 }
6458 }
6459 let is_surgical_tool = matches!(
6460 tool_name.as_str(),
6461 "create_directory"
6462 | "write_file"
6463 | "edit_file"
6464 | "patch_hunk"
6465 | "multi_replace_file_content"
6466 | "replace_file_content"
6467 | "move_file"
6468 | "delete_file"
6469 );
6470
6471 if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
6472 if let Some(prompt_args) =
6473 latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
6474 {
6475 *tool_name = "run_hematite_maintainer_workflow".to_string();
6476 *args = prompt_args;
6477 return;
6478 }
6479 }
6480 let is_generic_command_trigger = matches!(
6484 tool_name.as_str(),
6485 "shell" | "run_command" | "workflow" | "run"
6486 );
6487 if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
6488 if let Some(prompt_args) =
6489 latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
6490 {
6491 *tool_name = "run_workspace_workflow".to_string();
6492 *args = prompt_args;
6493 return;
6494 }
6495 }
6496 if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
6497 *tool_name = "inspect_host".to_string();
6498 *args = serde_json::json!({
6499 "topic": "fix_plan"
6500 });
6501 }
6502 fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
6503 fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
6504 fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
6505 fill_missing_event_query_args(tool_name, args, latest_user_prompt);
6506}
6507
6508fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
6509 format!(
6510 "{}:{}",
6511 tool_name,
6512 serde_json::to_string(args).unwrap_or_default()
6513 )
6514}
6515
6516fn normalized_tool_call_for_execution(
6517 tool_name: &str,
6518 raw_arguments: &str,
6519 gemma4_model: bool,
6520 latest_user_prompt: Option<&str>,
6521) -> (String, Value) {
6522 let normalized_arguments = if gemma4_model {
6523 crate::agent::inference::normalize_tool_argument_string(tool_name, raw_arguments)
6524 } else {
6525 raw_arguments.to_string()
6526 };
6527 let mut normalized_name = tool_name.to_string();
6528 let mut args = serde_json::from_str::<Value>(&normalized_arguments)
6529 .unwrap_or(Value::Object(Default::default()));
6530 rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
6531 (normalized_name, args)
6532}
6533
6534#[cfg(test)]
6535fn normalized_tool_call_key_for_dedupe(
6536 tool_name: &str,
6537 raw_arguments: &str,
6538 gemma4_model: bool,
6539 latest_user_prompt: Option<&str>,
6540) -> String {
6541 let (normalized_name, args) = normalized_tool_call_for_execution(
6542 tool_name,
6543 raw_arguments,
6544 gemma4_model,
6545 latest_user_prompt,
6546 );
6547 canonical_tool_call_key(&normalized_name, &args)
6548}
6549
6550impl ConversationManager {
6551 fn check_authorization(
6553 &self,
6554 name: &str,
6555 args: &serde_json::Value,
6556 config: &crate::agent::config::HematiteConfig,
6557 yolo_flag: bool,
6558 ) -> crate::agent::permission_enforcer::AuthorizationDecision {
6559 crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
6560 }
6561
6562 async fn process_tool_call(
6564 &self,
6565 mut call: ToolCallFn,
6566 config: crate::agent::config::HematiteConfig,
6567 yolo: bool,
6568 tx: mpsc::Sender<InferenceEvent>,
6569 real_id: String,
6570 ) -> ToolExecutionOutcome {
6571 let mut msg_results = Vec::new();
6572 let mut latest_target_dir = None;
6573 let mut plan_drafted_this_turn = false;
6574 let mut parsed_plan_handoff = None;
6575 let gemma4_model =
6576 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
6577 let normalized_arguments = if gemma4_model {
6578 crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments)
6579 } else {
6580 call.arguments.clone()
6581 };
6582
6583 let mut args: Value = match serde_json::from_str(&normalized_arguments) {
6585 Ok(v) => v,
6586 Err(_) => {
6587 match self
6588 .repair_tool_args(&call.name, &normalized_arguments, &tx)
6589 .await
6590 {
6591 Ok(v) => v,
6592 Err(e) => {
6593 let _ = tx
6594 .send(InferenceEvent::Thought(format!(
6595 "JSON Repair failed: {}",
6596 e
6597 )))
6598 .await;
6599 Value::Object(Default::default())
6600 }
6601 }
6602 }
6603 };
6604 let last_user_prompt = self
6605 .history
6606 .iter()
6607 .rev()
6608 .find(|message| message.role == "user")
6609 .map(|message| message.content.as_str());
6610 rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
6611
6612 let display = format_tool_display(&call.name, &args);
6613 let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
6614 let auth = self.check_authorization(&call.name, &args, &config, yolo);
6615
6616 let decision_result = match precondition_result {
6618 Err(e) => Err(e),
6619 Ok(_) => match auth {
6620 crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
6621 crate::agent::permission_enforcer::AuthorizationDecision::Ask {
6622 reason,
6623 source: _,
6624 } => {
6625 let mutation_label =
6626 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
6627 let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
6628 let _ = tx
6629 .send(InferenceEvent::ApprovalRequired {
6630 id: real_id.clone(),
6631 name: call.name.clone(),
6632 display: format!("{}\nWhy: {}", display, reason),
6633 diff: None,
6634 mutation_label,
6635 responder: approve_tx,
6636 })
6637 .await;
6638
6639 match approve_rx.await {
6640 Ok(true) => Ok(()),
6641 _ => Err("Declined by user".into()),
6642 }
6643 }
6644 crate::agent::permission_enforcer::AuthorizationDecision::Deny {
6645 reason, ..
6646 } => Err(reason),
6647 },
6648 };
6649 let blocked_by_policy =
6650 matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
6651
6652 let (output, is_error) = match decision_result {
6654 Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
6655 Err(e) => (format!("Error: {}", e), true),
6656 Ok(_) => {
6657 let _ = tx
6658 .send(InferenceEvent::ToolCallStart {
6659 id: real_id.clone(),
6660 name: call.name.clone(),
6661 args: display.clone(),
6662 })
6663 .await;
6664
6665 let result = if call.name.starts_with("lsp_") {
6666 let lsp = self.lsp_manager.clone();
6667 let path = args
6668 .get("path")
6669 .and_then(|v| v.as_str())
6670 .unwrap_or("")
6671 .to_string();
6672 let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
6673 let character =
6674 args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
6675
6676 match call.name.as_str() {
6677 "lsp_definitions" => {
6678 crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
6679 .await
6680 }
6681 "lsp_references" => {
6682 crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
6683 .await
6684 }
6685 "lsp_hover" => {
6686 crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
6687 }
6688 "lsp_search_symbol" => {
6689 let query = args
6690 .get("query")
6691 .and_then(|v| v.as_str())
6692 .unwrap_or_default()
6693 .to_string();
6694 crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
6695 }
6696 "lsp_rename_symbol" => {
6697 let new_name = args
6698 .get("new_name")
6699 .and_then(|v| v.as_str())
6700 .unwrap_or_default()
6701 .to_string();
6702 crate::tools::lsp_tools::lsp_rename_symbol(
6703 lsp, path, line, character, new_name,
6704 )
6705 .await
6706 }
6707 "lsp_get_diagnostics" => {
6708 crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
6709 }
6710 _ => Err(format!("Unknown LSP tool: {}", call.name)),
6711 }
6712 } else if call.name == "auto_pin_context" {
6713 let pts = args.get("paths").and_then(|v| v.as_array());
6714 let reason = args
6715 .get("reason")
6716 .and_then(|v| v.as_str())
6717 .unwrap_or("uninformed scoping");
6718 if let Some(arr) = pts {
6719 let mut pinned = Vec::new();
6720 {
6721 let mut guard = self.pinned_files.lock().await;
6722 const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; for v in arr.iter().take(3) {
6725 if let Some(p) = v.as_str() {
6726 if let Ok(meta) = std::fs::metadata(p) {
6727 if meta.len() > MAX_PINNED_SIZE {
6728 let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
6729 continue;
6730 }
6731 if let Ok(content) = std::fs::read_to_string(p) {
6732 guard.insert(p.to_string(), content);
6733 pinned.push(p.to_string());
6734 }
6735 }
6736 }
6737 }
6738 }
6739 let msg = format!(
6740 "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
6741 pinned.join(", "),
6742 reason
6743 );
6744 let _ = tx
6745 .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
6746 .await;
6747 Ok(msg)
6748 } else {
6749 Err("Missing 'paths' array for auto_pin_context.".to_string())
6750 }
6751 } else if call.name == "list_pinned" {
6752 let paths_msg = {
6753 let pinned = self.pinned_files.lock().await;
6754 if pinned.is_empty() {
6755 "No files are currently pinned.".to_string()
6756 } else {
6757 let paths: Vec<_> = pinned.keys().cloned().collect();
6758 format!(
6759 "Currently pinned files in active memory:\n- {}",
6760 paths.join("\n- ")
6761 )
6762 }
6763 };
6764 Ok(paths_msg)
6765 } else if call.name.starts_with("mcp__") {
6766 let mut mcp = self.mcp_manager.lock().await;
6767 match mcp.call_tool(&call.name, &args).await {
6768 Ok(res) => Ok(res),
6769 Err(e) => Err(e.to_string()),
6770 }
6771 } else if call.name == "swarm" {
6772 let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
6774 let max_workers = args
6775 .get("max_workers")
6776 .and_then(|v| v.as_u64())
6777 .unwrap_or(3) as usize;
6778
6779 let mut task_objs = Vec::new();
6780 if let Value::Array(arr) = tasks_val {
6781 for v in arr {
6782 let id = v
6783 .get("id")
6784 .and_then(|x| x.as_str())
6785 .unwrap_or("?")
6786 .to_string();
6787 let target = v
6788 .get("target")
6789 .and_then(|x| x.as_str())
6790 .unwrap_or("?")
6791 .to_string();
6792 let instruction = v
6793 .get("instruction")
6794 .and_then(|x| x.as_str())
6795 .unwrap_or("?")
6796 .to_string();
6797 task_objs.push(crate::agent::parser::WorkerTask {
6798 id,
6799 target,
6800 instruction,
6801 });
6802 }
6803 }
6804
6805 if task_objs.is_empty() {
6806 Err("No tasks provided for swarm.".to_string())
6807 } else {
6808 let (swarm_tx_internal, mut swarm_rx_internal) =
6809 tokio::sync::mpsc::channel(32);
6810 let tx_forwarder = tx.clone();
6811
6812 tokio::spawn(async move {
6814 while let Some(msg) = swarm_rx_internal.recv().await {
6815 match msg {
6816 crate::agent::swarm::SwarmMessage::Progress(id, p) => {
6817 let _ = tx_forwarder
6818 .send(InferenceEvent::Thought(format!(
6819 "Swarm [{}]: {}% complete",
6820 id, p
6821 )))
6822 .await;
6823 }
6824 crate::agent::swarm::SwarmMessage::ReviewRequest {
6825 worker_id,
6826 file_path,
6827 before: _,
6828 after: _,
6829 tx,
6830 } => {
6831 let (approve_tx, approve_rx) =
6832 tokio::sync::oneshot::channel::<bool>();
6833 let display = format!(
6834 "Swarm worker [{}]: Integrated changes into {:?}",
6835 worker_id, file_path
6836 );
6837 let _ = tx_forwarder
6838 .send(InferenceEvent::ApprovalRequired {
6839 id: format!("swarm_{}", worker_id),
6840 name: "swarm_apply".to_string(),
6841 display,
6842 diff: None,
6843 mutation_label: Some(
6844 "Swarm Agentic Integration".to_string(),
6845 ),
6846 responder: approve_tx,
6847 })
6848 .await;
6849 if let Ok(approved) = approve_rx.await {
6850 let response = if approved {
6851 crate::agent::swarm::ReviewResponse::Accept
6852 } else {
6853 crate::agent::swarm::ReviewResponse::Reject
6854 };
6855 let _ = tx.send(response);
6856 }
6857 }
6858 crate::agent::swarm::SwarmMessage::Done => {}
6859 }
6860 }
6861 });
6862
6863 let coordinator = self.swarm_coordinator.clone();
6864 match coordinator
6865 .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
6866 .await
6867 {
6868 Ok(_) => Ok(
6869 "Swarm execution completed. Check files for integration results."
6870 .to_string(),
6871 ),
6872 Err(e) => Err(format!("Swarm failure: {}", e)),
6873 }
6874 }
6875 } else if call.name == "vision_analyze" {
6876 crate::tools::vision::vision_analyze(&self.engine, &args).await
6877 } else if matches!(
6878 call.name.as_str(),
6879 "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
6880 ) && !yolo
6881 {
6882 let diff_result = match call.name.as_str() {
6888 "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
6889 "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
6890 "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
6891 _ => crate::tools::file_ops::compute_msr_diff(&args),
6892 };
6893 match diff_result {
6894 Ok(diff_text) => {
6895 let path_label =
6896 args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
6897 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
6898 let mutation_label =
6899 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
6900 let _ = tx
6901 .send(InferenceEvent::ApprovalRequired {
6902 id: real_id.clone(),
6903 name: call.name.clone(),
6904 display: format!("Edit preview: {}", path_label),
6905 diff: Some(diff_text),
6906 mutation_label,
6907 responder: appr_tx,
6908 })
6909 .await;
6910 match appr_rx.await {
6911 Ok(true) => dispatch_tool(&call.name, &args, &config).await,
6912 _ => Err("Edit declined by user.".into()),
6913 }
6914 }
6915 Err(_) => dispatch_tool(&call.name, &args, &config).await,
6918 }
6919 } else if call.name == "verify_build" {
6920 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
6923 } else if call.name == "shell" {
6924 crate::tools::shell::execute_streaming(&args, tx.clone()).await
6927 } else {
6928 dispatch_tool(&call.name, &args, &config).await
6929 };
6930
6931 match result {
6932 Ok(o) => (o, false),
6933 Err(e) => (format!("Error: {}", e), true),
6934 }
6935 }
6936 };
6937
6938 {
6940 if let Ok(mut econ) = self.engine.economics.lock() {
6941 econ.record_tool(&call.name, !is_error);
6942 }
6943 }
6944
6945 if !is_error {
6946 if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
6947 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6948 if call.name == "inspect_lines" {
6949 self.record_line_inspection(path).await;
6950 } else {
6951 self.record_read_observation(path).await;
6952 }
6953 }
6954 }
6955
6956 if call.name == "verify_build" {
6957 let ok = output.contains("BUILD OK")
6958 || output.contains("BUILD SUCCESS")
6959 || output.contains("BUILD OKAY");
6960 self.record_verify_build_result(ok, &output).await;
6961 }
6962
6963 if matches!(
6964 call.name.as_str(),
6965 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
6966 ) || is_mcp_mutating_tool(&call.name)
6967 {
6968 if call.name == "write_file" {
6969 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6970 if path.ends_with("PLAN.md") {
6971 plan_drafted_this_turn = true;
6972 if !is_error {
6973 if let Some(content) = args.get("content").and_then(|v| v.as_str())
6974 {
6975 let resolved = crate::tools::file_ops::resolve_candidate(path);
6976 let _ = crate::tools::plan::sync_plan_blueprint_for_path(
6977 &resolved, content,
6978 );
6979 parsed_plan_handoff =
6980 crate::tools::plan::parse_plan_handoff(content);
6981 }
6982 }
6983 }
6984 }
6985 }
6986 self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
6987 .await;
6988 }
6989
6990 if call.name == "create_directory" {
6991 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
6992 let resolved = crate::tools::file_ops::resolve_candidate(path);
6993 latest_target_dir = Some(resolved.to_string_lossy().to_string());
6994 }
6995 }
6996
6997 if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
6998 msg_results.push(receipt);
6999 }
7000 }
7001
7002 if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
7006 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
7007 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
7008 let ext = std::path::Path::new(path)
7009 .extension()
7010 .and_then(|e| e.to_str())
7011 .unwrap_or("");
7012 const SKIP_EXTS: &[&str] = &[
7013 "md",
7014 "toml",
7015 "json",
7016 "txt",
7017 "yml",
7018 "yaml",
7019 "cfg",
7020 "csv",
7021 "lock",
7022 "gitignore",
7023 ];
7024 let line_count = content.lines().count();
7025 const WEB_EXTS: &[&str] = &[
7028 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
7029 ];
7030 let is_web = WEB_EXTS.contains(&ext);
7031 let min_lines = if is_web { 5 } else { 50 };
7032 if !path.is_empty()
7033 && !content.is_empty()
7034 && !SKIP_EXTS.contains(&ext)
7035 && line_count >= min_lines
7036 {
7037 if let Some(critique) = self.run_critic_check(path, content, &tx).await {
7038 msg_results.push(ChatMessage::system(&format!(
7039 "[CRITIC AUTO-FIX REQUIRED — {}]\n\
7040 Fix ALL issues below before sending your final response. \
7041 Call the appropriate edit tools now.\n\n{}",
7042 path, critique
7043 )));
7044 }
7045 }
7046 }
7047
7048 ToolExecutionOutcome {
7049 call_id: real_id,
7050 tool_name: call.name,
7051 args,
7052 output,
7053 is_error,
7054 blocked_by_policy,
7055 msg_results,
7056 latest_target_dir,
7057 plan_drafted_this_turn,
7058 parsed_plan_handoff,
7059 }
7060 }
7061}
7062
7063struct ToolExecutionOutcome {
7066 call_id: String,
7067 tool_name: String,
7068 args: Value,
7069 output: String,
7070 is_error: bool,
7071 blocked_by_policy: bool,
7072 msg_results: Vec<ChatMessage>,
7073 latest_target_dir: Option<String>,
7074 plan_drafted_this_turn: bool,
7075 parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
7076}
7077
7078#[derive(Clone)]
7079struct CachedToolResult {
7080 tool_name: String,
7081}
7082
7083fn is_code_like_path(path: &str) -> bool {
7084 let ext = std::path::Path::new(path)
7085 .extension()
7086 .and_then(|e| e.to_str())
7087 .unwrap_or("")
7088 .to_ascii_lowercase();
7089 matches!(
7090 ext.as_str(),
7091 "rs" | "js"
7092 | "ts"
7093 | "tsx"
7094 | "jsx"
7095 | "py"
7096 | "go"
7097 | "java"
7098 | "c"
7099 | "cpp"
7100 | "cc"
7101 | "h"
7102 | "hpp"
7103 | "cs"
7104 | "swift"
7105 | "kt"
7106 | "kts"
7107 | "rb"
7108 | "php"
7109 )
7110}
7111
7112pub fn format_tool_display(name: &str, args: &Value) -> String {
7115 let get = |key: &str| {
7116 args.get(key)
7117 .and_then(|v| v.as_str())
7118 .unwrap_or("")
7119 .to_string()
7120 };
7121 match name {
7122 "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
7123 "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
7124 "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
7125 "describe_toolchain" => format!("describe toolchain {}", get("topic")),
7126 "inspect_host" => format!("inspect host {}", get("topic")),
7127 "write_file"
7128 | "read_file"
7129 | "edit_file"
7130 | "patch_hunk"
7131 | "inspect_lines"
7132 | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
7133 "grep_files" => format!(
7134 "grep_files pattern='{}' path='{}'",
7135 get("pattern"),
7136 get("path")
7137 ),
7138 "list_files" => format!("list_files `{}`", get("path")),
7139 "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
7140 _ => {
7141 let rep = format!("{} {:?}", name, args);
7143 if rep.len() > 100 {
7144 format!("{}... (truncated)", &rep[..100])
7145 } else {
7146 rep
7147 }
7148 }
7149 }
7150}
7151
7152pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
7155 let lower = command.to_ascii_lowercase();
7156 [
7157 "$env:path",
7158 "pathvariable",
7159 "pip --version",
7160 "pipx --version",
7161 "winget --version",
7162 "choco",
7163 "scoop",
7164 "get-childitem",
7165 "gci ",
7166 "where.exe",
7167 "where ",
7168 "cargo --version",
7169 "rustc --version",
7170 "git --version",
7171 "node --version",
7172 "npm --version",
7173 "pnpm --version",
7174 "python --version",
7175 "python3 --version",
7176 "deno --version",
7177 "go version",
7178 "dotnet --version",
7179 "uv --version",
7180 "netstat",
7181 "findstr",
7182 "get-nettcpconnection",
7183 "tcpconnection",
7184 "listening",
7185 "ss -",
7186 "ss ",
7187 "lsof",
7188 "tasklist",
7189 "ipconfig",
7190 "get-netipconfiguration",
7191 "get-netadapter",
7192 "route print",
7193 "ifconfig",
7194 "ip addr",
7195 "ip route",
7196 "resolv.conf",
7197 "get-service",
7198 "sc query",
7199 "systemctl",
7200 "service --status-all",
7201 "get-process",
7202 "working set",
7203 "ps -eo",
7204 "ps aux",
7205 "desktop",
7206 "downloads",
7207 "get-netfirewallprofile",
7208 "win32_powerplan",
7209 "win32_operatingsystem",
7210 "win32_processor",
7211 "wmic",
7212 "loadpercentage",
7213 "totalvisiblememory",
7214 "freephysicalmemory",
7215 "get-wmiobject",
7216 "get-ciminstance",
7217 "get-cpu",
7218 "processorname",
7219 "clockspeed",
7220 "top memory",
7221 "top cpu",
7222 "resource usage",
7223 "powercfg",
7224 "uptime",
7225 "lastbootuptime",
7226 "hklm:",
7228 "hkcu:",
7229 "hklm:\\",
7230 "hkcu:\\",
7231 "currentversion",
7232 "productname",
7233 "displayversion",
7234 "get-itemproperty",
7235 "get-itempropertyvalue",
7236 "get-windowsupdatelog",
7238 "windowsupdatelog",
7239 "microsoft.update.session",
7240 "createupdatesearcher",
7241 "wuauserv",
7242 "usoclient",
7243 "get-hotfix",
7244 "wu_",
7245 "get-mpcomputerstatus",
7247 "get-mppreference",
7248 "get-mpthreat",
7249 "start-mpscan",
7250 "win32_computersecurity",
7251 "softwarelicensingproduct",
7252 "enablelua",
7253 "get-netfirewallrule",
7254 "netfirewallprofile",
7255 "antivirus",
7256 "defenderstatus",
7257 "get-physicaldisk",
7259 "get-disk",
7260 "get-volume",
7261 "get-psdrive",
7262 "psdrive",
7263 "manage-bde",
7264 "bitlockervolume",
7265 "get-bitlockervolume",
7266 "get-smbencryptionstatus",
7267 "smbencryption",
7268 "get-netlanmanagerconnection",
7269 "lanmanager",
7270 "msstoragedriver_failurepredic",
7271 "win32_diskdrive",
7272 "smartstatus",
7273 "diskstatus",
7274 "get-counter",
7275 "intensity",
7276 "benchmark",
7277 "thrash",
7278 "get-item",
7279 "test-path",
7280 "gpresult",
7282 "applied gpo",
7283 "cert:\\",
7284 "cert:",
7285 "component based servicing",
7286 "componentstore",
7287 "get-computerinfo",
7288 "win32_computersystem",
7289 "win32_battery",
7291 "batterystaticdata",
7292 "batteryfullchargedcapacity",
7293 "batterystatus",
7294 "estimatedchargeremaining",
7295 "get-winevent",
7297 "eventid",
7298 "bugcheck",
7299 "kernelpower",
7300 "win32_ntlogevent",
7301 "filterhashtable",
7302 "get-scheduledtask",
7304 "get-scheduledtaskinfo",
7305 "schtasks",
7306 "taskscheduler",
7307 "get-acl",
7308 "icacls",
7309 "takeown",
7310 "event id 4624",
7311 "eventid 4624",
7312 "who logged in",
7313 "logon history",
7314 "login history",
7315 "get-smbshare",
7316 "net share",
7317 "mbps",
7318 "throughput",
7319 "whoami",
7320 "get-ciminstance win32",
7322 "get-wmiobject win32",
7323 "arp -",
7325 "arp -a",
7326 "tracert ",
7327 "traceroute ",
7328 "tracepath ",
7329 "get-dnsclientcache",
7330 "ipconfig /displaydns",
7331 "get-netroute",
7332 "get-netneighbor",
7333 "net view",
7334 "get-smbconnection",
7335 "get-smbmapping",
7336 "get-psdrive",
7337 "fdrespub",
7338 "fdphost",
7339 "ssdpsrv",
7340 "upnphost",
7341 "avahi-browse",
7342 "route print",
7343 "ip neigh",
7344 "get-pnpdevice -class audioendpoint",
7346 "get-pnpdevice -class media",
7347 "win32_sounddevice",
7348 "audiosrv",
7349 "audioendpointbuilder",
7350 "windows audio",
7351 "get-pnpdevice -class bluetooth",
7352 "bthserv",
7353 "bthavctpsvc",
7354 "btagservice",
7355 "bluetoothuserservice",
7356 "msiserver",
7357 "appxsvc",
7358 "clipsvc",
7359 "installservice",
7360 "desktopappinstaller",
7361 "microsoft.windowsstore",
7362 "get-appxpackage microsoft.desktopappinstaller",
7363 "get-appxpackage microsoft.windowsstore",
7364 "winget source",
7365 "winget --info",
7366 "onedrive",
7367 "onedrive.exe",
7368 "files on-demand",
7369 "known folder backup",
7370 "disablefilesyncngsc",
7371 "kfmsilentoptin",
7372 "kfmblockoptin",
7373 "get-process chrome",
7374 "get-process msedge",
7375 "get-process firefox",
7376 "get-process msedgewebview2",
7377 "google chrome",
7378 "microsoft edge",
7379 "mozilla firefox",
7380 "webview2",
7381 "msedgewebview2",
7382 "startmenuinternet",
7383 "urlassociations\\http\\userchoice",
7384 "urlassociations\\https\\userchoice",
7385 "software\\policies\\microsoft\\edge",
7386 "software\\policies\\google\\chrome",
7387 "get-winevent",
7388 "event id",
7389 "eventlog",
7390 "event viewer",
7391 "wevtutil",
7392 "cmdkey",
7393 "credential manager",
7394 "get-tpm",
7395 "confirm-securebootuefi",
7396 "win32_tpm",
7397 "dsregcmd",
7398 "webauthmanager",
7399 "web account manager",
7400 "tokenbroker",
7401 "token broker",
7402 "aad broker",
7403 "brokerplugin",
7404 "microsoft.aad.brokerplugin",
7405 "workplace join",
7406 "device registration",
7407 "secure boot",
7408 "get-aduser",
7410 "get-addomain",
7411 "get-adforest",
7412 "get-adgroup",
7413 "get-adcomputer",
7414 "activedirectory",
7415 "get-localuser",
7416 "get-localgroup",
7417 "get-localgroupmember",
7418 "net user",
7419 "net localgroup",
7420 "netsh winhttp show proxy",
7421 "get-itemproperty.*proxy",
7422 "get-netadapter",
7423 "netsh wlan show",
7424 "test-netconnection",
7425 "resolve-dnsname",
7426 "nslookup",
7427 "dig ",
7428 "gethostentry",
7429 "gethostaddresses",
7430 "getipaddresses",
7431 "[system.net.dns]",
7432 "net.dns]",
7433 "get-netfirewallrule",
7434 "docker ps",
7436 "docker info",
7437 "docker images",
7438 "docker container",
7439 "docker inspect",
7440 "docker volume",
7441 "docker system df",
7442 "docker compose ls",
7443 "wsl --list",
7444 "wsl -l",
7445 "wsl --status",
7446 "wsl --version",
7447 "wsl -d",
7448 "wsl df",
7449 "wsl du",
7450 "/mnt/c",
7451 "ssh -v",
7452 "get-service sshd",
7453 "get-service -name sshd",
7454 "cat ~/.ssh",
7455 "ls ~/.ssh",
7456 "ls -la ~/.ssh",
7457 "get-childitem env:",
7459 "dir env:",
7460 "printenv",
7461 "[environment]::getenvironmentvariable",
7462 "get-content.*hosts",
7463 "cat /etc/hosts",
7464 "type c:\\windows\\system32\\drivers\\etc\\hosts",
7465 "git config --global --list",
7466 "git config --list",
7467 "git config --global",
7468 "get-service mysql",
7470 "get-service postgresql",
7471 "get-service mongodb",
7472 "get-service redis",
7473 "get-service mssql",
7474 "get-service mariadb",
7475 "systemctl status postgresql",
7476 "systemctl status mysql",
7477 "systemctl status mongod",
7478 "systemctl status redis",
7479 "winget list",
7481 "get-package",
7482 "get-itempropert.*uninstall",
7483 "dpkg --get-selections",
7484 "rpm -qa",
7485 "brew list",
7486 "get-localuser",
7488 "get-localgroupmember",
7489 "net user",
7490 "query user",
7491 "net localgroup administrators",
7492 "auditpol /get",
7494 "auditpol",
7495 "get-smbshare",
7497 "get-smbserverconfiguration",
7498 "net share",
7499 "net use",
7500 "get-dnsclientserveraddress",
7502 "get-dnsclientdohserveraddress",
7503 "get-dnsclientglobalsetting",
7504 ]
7505 .iter()
7506 .any(|needle| lower.contains(needle))
7507 || lower.starts_with("host ")
7508}
7509
7510fn cap_output(text: &str, max_bytes: usize) -> String {
7513 cap_output_for_tool(text, max_bytes, "output")
7514}
7515
7516fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
7521 if text.len() <= max_bytes {
7522 return text.to_string();
7523 }
7524
7525 let scratch_path = write_output_to_scratch(text, tool_name);
7527
7528 let mut split_at = max_bytes;
7529 while !text.is_char_boundary(split_at) && split_at > 0 {
7530 split_at -= 1;
7531 }
7532
7533 let tail = match &scratch_path {
7534 Some(p) => format!(
7535 "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
7536 text.len(),
7537 text.lines().count(),
7538 p
7539 ),
7540 None => format!("\n... [output capped at {}B]", max_bytes),
7541 };
7542
7543 format!("{}{}", &text[..split_at], tail)
7544}
7545
7546fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
7549 let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
7550 if std::fs::create_dir_all(&scratch_dir).is_err() {
7551 return None;
7552 }
7553 let ts = std::time::SystemTime::now()
7554 .duration_since(std::time::UNIX_EPOCH)
7555 .map(|d| d.as_secs())
7556 .unwrap_or(0);
7557 let safe_name: String = tool_name
7559 .chars()
7560 .map(|c| {
7561 if c.is_alphanumeric() || c == '_' {
7562 c
7563 } else {
7564 '_'
7565 }
7566 })
7567 .collect();
7568 let filename = format!("{}_{}.txt", safe_name, ts);
7569 let abs_path = scratch_dir.join(&filename);
7570 if std::fs::write(&abs_path, text).is_err() {
7571 return None;
7572 }
7573 Some(format!(".hematite/scratch/{}", filename))
7574}
7575
7576#[derive(Default)]
7577struct PromptBudgetStats {
7578 summarized_tool_results: usize,
7579 collapsed_tool_results: usize,
7580 trimmed_chat_messages: usize,
7581 dropped_messages: usize,
7582}
7583
7584fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
7585 crate::agent::inference::estimate_message_batch_tokens(messages)
7586}
7587
7588fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
7589 let budget = compaction::SummaryCompressionBudget {
7590 max_chars,
7591 max_lines: 3,
7592 max_line_chars: max_chars.clamp(80, 240),
7593 };
7594 let compressed = compaction::compress_summary(text, budget).summary;
7595 if compressed.is_empty() {
7596 String::new()
7597 } else {
7598 compressed
7599 }
7600}
7601
7602fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
7603 let tool_name = message.name.as_deref().unwrap_or("tool");
7604 let body = summarize_prompt_blob(message.content.as_str(), 320);
7605 format!(
7606 "[Prompt-budget summary of prior `{}` result]\n{}",
7607 tool_name, body
7608 )
7609}
7610
7611fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
7612 let role = message.role.as_str();
7613 let body = summarize_prompt_blob(message.content.as_str(), 240);
7614 format!(
7615 "[Prompt-budget summary of earlier {} message]\n{}",
7616 role, body
7617 )
7618}
7619
7620fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
7621 if messages.len() > 1 && messages[1].role != "user" {
7622 messages.insert(1, ChatMessage::user("Continuing previous context..."));
7623 }
7624}
7625
7626fn enforce_prompt_budget(
7627 prompt_msgs: &mut Vec<ChatMessage>,
7628 context_length: usize,
7629) -> Option<String> {
7630 let target_tokens = ((context_length as f64) * 0.68) as usize;
7631 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7632 return None;
7633 }
7634
7635 let mut stats = PromptBudgetStats::default();
7636
7637 let mut tool_indices: Vec<usize> = prompt_msgs
7639 .iter()
7640 .enumerate()
7641 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
7642 .collect();
7643 for idx in tool_indices.iter().rev().copied() {
7644 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7645 break;
7646 }
7647 let original = prompt_msgs[idx].content.as_str().to_string();
7648 if original.len() > 1200 {
7649 prompt_msgs[idx].content =
7650 MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
7651 stats.summarized_tool_results += 1;
7652 }
7653 }
7654
7655 tool_indices = prompt_msgs
7657 .iter()
7658 .enumerate()
7659 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
7660 .collect();
7661 if tool_indices.len() > 2 {
7662 for idx in tool_indices
7663 .iter()
7664 .take(tool_indices.len().saturating_sub(2))
7665 .copied()
7666 {
7667 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7668 break;
7669 }
7670 prompt_msgs[idx].content = MessageContent::Text(
7671 "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
7672 );
7673 stats.collapsed_tool_results += 1;
7674 }
7675 }
7676
7677 let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
7679 for idx in 1..prompt_msgs.len() {
7680 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
7681 break;
7682 }
7683 if Some(idx) == last_user_idx {
7684 continue;
7685 }
7686 let role = prompt_msgs[idx].role.as_str();
7687 if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
7688 prompt_msgs[idx].content =
7689 MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
7690 stats.trimmed_chat_messages += 1;
7691 }
7692 }
7693
7694 let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
7696 let mut idx = 1usize;
7697 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
7698 if Some(idx) == preserve_last_user_idx {
7699 idx += 1;
7700 if idx >= prompt_msgs.len() {
7701 break;
7702 }
7703 continue;
7704 }
7705 if idx >= prompt_msgs.len() {
7706 break;
7707 }
7708 prompt_msgs.remove(idx);
7709 stats.dropped_messages += 1;
7710 }
7711
7712 normalize_prompt_start(prompt_msgs);
7713
7714 let new_tokens = estimate_prompt_tokens(prompt_msgs);
7715 if stats.summarized_tool_results == 0
7716 && stats.collapsed_tool_results == 0
7717 && stats.trimmed_chat_messages == 0
7718 && stats.dropped_messages == 0
7719 {
7720 return None;
7721 }
7722
7723 Some(format!(
7724 "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).",
7725 new_tokens,
7726 target_tokens,
7727 stats.summarized_tool_results,
7728 stats.collapsed_tool_results,
7729 stats.trimmed_chat_messages,
7730 stats.dropped_messages
7731 ))
7732}
7733
7734fn is_quick_tool_request(input: &str) -> bool {
7739 let lower = input.to_lowercase();
7740 if lower.contains("run_code") || lower.contains("run code") {
7742 return true;
7743 }
7744 let is_short = input.len() < 120;
7746 let compute_keywords = [
7747 "calculate",
7748 "compute",
7749 "execute",
7750 "run this",
7751 "test this",
7752 "what is ",
7753 "how much",
7754 "how many",
7755 "convert ",
7756 "print ",
7757 ];
7758 if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
7759 return true;
7760 }
7761 false
7762}
7763
7764fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
7765 let mut chunks = Vec::new();
7766 let mut current = String::new();
7767 let mut count = 0;
7768
7769 for ch in text.chars() {
7770 current.push(ch);
7771 if ch == ' ' || ch == '\n' {
7772 count += 1;
7773 if count >= words_per_chunk {
7774 chunks.push(current.clone());
7775 current.clear();
7776 count = 0;
7777 }
7778 }
7779 }
7780 if !current.is_empty() {
7781 chunks.push(current);
7782 }
7783 chunks
7784}
7785
7786fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
7787 if call.name != "read_file" {
7788 return None;
7789 }
7790 let normalized_arguments =
7791 crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments);
7792 let args: Value = serde_json::from_str(&normalized_arguments).ok()?;
7793 let path = args.get("path").and_then(|v| v.as_str())?;
7794 Some(normalize_workspace_path(path))
7795}
7796
7797fn order_batch_reads_first(
7798 calls: Vec<crate::agent::inference::ToolCallResponse>,
7799) -> (
7800 Vec<crate::agent::inference::ToolCallResponse>,
7801 Option<String>,
7802) {
7803 let has_reads = calls.iter().any(|c| {
7804 matches!(
7805 c.function.name.as_str(),
7806 "read_file" | "inspect_lines" | "grep_files" | "list_files"
7807 )
7808 });
7809 let has_edits = calls.iter().any(|c| {
7810 matches!(
7811 c.function.name.as_str(),
7812 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
7813 )
7814 });
7815 if has_reads && has_edits {
7816 let reads: Vec<_> = calls
7817 .into_iter()
7818 .filter(|c| {
7819 !matches!(
7820 c.function.name.as_str(),
7821 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
7822 )
7823 })
7824 .collect();
7825 let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
7826 (reads, note)
7827 } else {
7828 (calls, None)
7829 }
7830}
7831
7832fn grep_output_is_high_fanout(output: &str) -> bool {
7833 let Some(summary) = output.lines().next() else {
7834 return false;
7835 };
7836 let hunk_count = summary
7837 .split(", ")
7838 .find_map(|part| {
7839 part.strip_suffix(" hunk(s)")
7840 .and_then(|value| value.parse::<usize>().ok())
7841 })
7842 .unwrap_or(0);
7843 let match_count = summary
7844 .split(' ')
7845 .next()
7846 .and_then(|value| value.parse::<usize>().ok())
7847 .unwrap_or(0);
7848 hunk_count >= 8 || match_count >= 12
7849}
7850
7851fn build_system_with_corrections(
7852 base: &str,
7853 hints: &[String],
7854 gpu: &Arc<GpuState>,
7855 git: &Arc<crate::agent::git_monitor::GitState>,
7856 config: &crate::agent::config::HematiteConfig,
7857) -> String {
7858 let mut system_msg = base.to_string();
7859
7860 system_msg.push_str("\n\n# Permission Mode\n");
7862 let mode_label = match config.mode {
7863 crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
7864 crate::agent::config::PermissionMode::Developer => "DEVELOPER",
7865 crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
7866 };
7867 system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
7868
7869 if config.mode == crate::agent::config::PermissionMode::ReadOnly {
7870 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");
7871 } else {
7872 system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
7873 }
7874
7875 let (used, total) = gpu.read();
7877 if total > 0 {
7878 system_msg.push_str("\n\n# Terminal Hardware Context\n");
7879 system_msg.push_str(&format!(
7880 "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
7881 gpu.gpu_name(),
7882 used as f64 / 1024.0,
7883 total as f64 / 1024.0,
7884 gpu.ratio() * 100.0
7885 ));
7886 system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
7887 }
7888
7889 system_msg.push_str("\n\n# Git Repository Context\n");
7891 let git_status_label = git.label();
7892 let git_url = git.url();
7893 system_msg.push_str(&format!(
7894 "REMOTE STATUS: {} | URL: {}\n",
7895 git_status_label, git_url
7896 ));
7897
7898 let root = crate::tools::file_ops::workspace_root();
7900 if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
7901 system_msg.push_str("\nGit status snapshot:\n");
7902 system_msg.push_str(&status_snapshot);
7903 system_msg.push_str("\n");
7904 }
7905
7906 if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
7907 system_msg.push_str("\nGit diff snapshot:\n");
7908 system_msg.push_str(&diff_snapshot);
7909 system_msg.push_str("\n");
7910 }
7911
7912 if git_status_label == "NONE" {
7913 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");
7914 } else if git_status_label == "BEHIND" {
7915 system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
7916 }
7917
7918 if hints.is_empty() {
7923 return system_msg;
7924 }
7925 system_msg.push_str("\n\n# Formatting Corrections\n");
7926 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");
7927 for hint in hints {
7928 system_msg.push_str(&format!("- {}\n", hint));
7929 }
7930 system_msg
7931}
7932
7933fn route_model<'a>(
7934 user_input: &str,
7935 fast_model: Option<&'a str>,
7936 think_model: Option<&'a str>,
7937) -> Option<&'a str> {
7938 let text = user_input.to_lowercase();
7939 let is_think = text.contains("refactor")
7940 || text.contains("rewrite")
7941 || text.contains("implement")
7942 || text.contains("create")
7943 || text.contains("fix")
7944 || text.contains("debug");
7945 let is_fast = text.contains("what")
7946 || text.contains("show")
7947 || text.contains("find")
7948 || text.contains("list")
7949 || text.contains("status");
7950
7951 if is_think && think_model.is_some() {
7952 return think_model;
7953 } else if is_fast && fast_model.is_some() {
7954 return fast_model;
7955 }
7956 None
7957}
7958
7959fn is_parallel_safe(name: &str) -> bool {
7960 let metadata = crate::agent::inference::tool_metadata_for_name(name);
7961 !metadata.mutates_workspace && !metadata.external_surface
7962}
7963
7964fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
7965 if docs_only_mode {
7966 return true;
7967 }
7968
7969 let lower = query.to_ascii_lowercase();
7970 [
7971 "what did we decide",
7972 "why did we decide",
7973 "what did we say",
7974 "what did we do",
7975 "earlier today",
7976 "yesterday",
7977 "last week",
7978 "last month",
7979 "earlier",
7980 "remember",
7981 "session",
7982 "import",
7983 ]
7984 .iter()
7985 .any(|needle| lower.contains(needle))
7986 || lower
7987 .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
7988 .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
7989}
7990
7991#[cfg(test)]
7992mod tests {
7993 use super::*;
7994
7995 #[test]
7996 fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
7997 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."}"#;
7998 let class = classify_runtime_failure(detail);
7999 assert_eq!(class, RuntimeFailureClass::ContextWindow);
8000 assert_eq!(class.tag(), "context_window");
8001 assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
8002 }
8003
8004 #[test]
8005 fn runtime_failure_maps_to_provider_and_checkpoint_state() {
8006 assert_eq!(
8007 provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
8008 Some(ProviderRuntimeState::ContextWindow)
8009 );
8010 assert_eq!(
8011 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
8012 Some(OperatorCheckpointState::BlockedContextWindow)
8013 );
8014 assert_eq!(
8015 provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
8016 Some(ProviderRuntimeState::Degraded)
8017 );
8018 assert_eq!(
8019 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
8020 None
8021 );
8022 }
8023
8024 #[test]
8025 fn intent_router_treats_tool_registry_ownership_as_product_truth() {
8026 let intent = classify_query_intent(
8027 WorkflowMode::ReadOnly,
8028 "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
8029 );
8030 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8031 assert_eq!(
8032 intent.direct_answer,
8033 Some(DirectAnswerKind::ToolRegistryOwnership)
8034 );
8035 }
8036
8037 #[test]
8038 fn intent_router_treats_tool_classes_as_product_truth() {
8039 let intent = classify_query_intent(
8040 WorkflowMode::ReadOnly,
8041 "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.",
8042 );
8043 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8044 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
8045 }
8046
8047 #[test]
8048 fn tool_registry_ownership_answer_mentions_new_owner_file() {
8049 let answer = build_tool_registry_ownership_answer();
8050 assert!(answer.contains("src/agent/tool_registry.rs"));
8051 assert!(answer.contains("builtin dispatch path"));
8052 assert!(answer.contains("src/agent/conversation.rs"));
8053 }
8054
8055 #[test]
8056 fn intent_router_treats_mcp_lifecycle_as_product_truth() {
8057 let intent = classify_query_intent(
8058 WorkflowMode::ReadOnly,
8059 "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
8060 );
8061 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8062 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
8063 }
8064
8065 #[test]
8066 fn intent_router_short_circuits_unsafe_commit_pressure() {
8067 let intent = classify_query_intent(
8068 WorkflowMode::Auto,
8069 "Make a code change, skip verification, and commit it immediately.",
8070 );
8071 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
8072 assert_eq!(
8073 intent.direct_answer,
8074 Some(DirectAnswerKind::UnsafeWorkflowPressure)
8075 );
8076 }
8077
8078 #[test]
8079 fn unsafe_workflow_pressure_answer_requires_verification() {
8080 let answer = build_unsafe_workflow_pressure_answer();
8081 assert!(answer.contains("should not skip verification"));
8082 assert!(answer.contains("run the appropriate verification path"));
8083 assert!(answer.contains("only then commit"));
8084 }
8085
8086 #[test]
8087 fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
8088 let intent = classify_query_intent(
8089 WorkflowMode::ReadOnly,
8090 "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.",
8091 );
8092 assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
8093 assert!(intent.architecture_overview_mode);
8094 assert_eq!(intent.direct_answer, None);
8095 }
8096
8097 #[test]
8098 fn intent_router_marks_host_inspection_questions() {
8099 let intent = classify_query_intent(
8100 WorkflowMode::Auto,
8101 "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.",
8102 );
8103 assert!(intent.host_inspection_mode);
8104 assert_eq!(
8105 preferred_host_inspection_topic(
8106 "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."
8107 ),
8108 Some("summary")
8109 );
8110 }
8111
8112 #[test]
8113 fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
8114 assert!(should_use_vein_in_chat(
8115 "What did we decide on 2026-04-09 about docs-only mode?",
8116 false
8117 ));
8118 assert!(should_use_vein_in_chat("Summarize these local notes", true));
8119 assert!(!should_use_vein_in_chat("Tell me a joke", false));
8120 }
8121
8122 #[test]
8123 fn shell_host_inspection_guard_matches_path_and_version_commands() {
8124 assert!(shell_looks_like_structured_host_inspection(
8125 "$env:PATH -split ';'"
8126 ));
8127 assert!(shell_looks_like_structured_host_inspection(
8128 "cargo --version"
8129 ));
8130 assert!(shell_looks_like_structured_host_inspection(
8131 "Get-NetTCPConnection -LocalPort 3000"
8132 ));
8133 assert!(shell_looks_like_structured_host_inspection(
8134 "netstat -ano | findstr :3000"
8135 ));
8136 assert!(shell_looks_like_structured_host_inspection(
8137 "Get-Process | Sort-Object WS -Descending"
8138 ));
8139 assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
8140 assert!(shell_looks_like_structured_host_inspection("Get-Service"));
8141 assert!(shell_looks_like_structured_host_inspection(
8142 "winget --version"
8143 ));
8144 assert!(shell_looks_like_structured_host_inspection(
8145 "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
8146 ));
8147 assert!(shell_looks_like_structured_host_inspection(
8148 "Get-NetNeighbor -AddressFamily IPv4"
8149 ));
8150 assert!(shell_looks_like_structured_host_inspection(
8151 "Get-SmbConnection"
8152 ));
8153 assert!(shell_looks_like_structured_host_inspection(
8154 "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
8155 ));
8156 assert!(shell_looks_like_structured_host_inspection(
8157 "Get-PnpDevice -Class AudioEndpoint"
8158 ));
8159 assert!(shell_looks_like_structured_host_inspection(
8160 "Get-CimInstance Win32_SoundDevice"
8161 ));
8162 assert!(shell_looks_like_structured_host_inspection(
8163 "Get-PnpDevice -Class Bluetooth"
8164 ));
8165 assert!(shell_looks_like_structured_host_inspection(
8166 "Get-Service bthserv,BthAvctpSvc,BTAGService"
8167 ));
8168 assert!(shell_looks_like_structured_host_inspection(
8169 "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
8170 ));
8171 assert!(shell_looks_like_structured_host_inspection(
8172 "Get-AppxPackage Microsoft.DesktopAppInstaller"
8173 ));
8174 assert!(shell_looks_like_structured_host_inspection(
8175 "winget source list"
8176 ));
8177 assert!(shell_looks_like_structured_host_inspection(
8178 "Get-Process OneDrive"
8179 ));
8180 assert!(shell_looks_like_structured_host_inspection(
8181 "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
8182 ));
8183 assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
8184 assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
8185 assert!(shell_looks_like_structured_host_inspection(
8186 "Confirm-SecureBootUEFI"
8187 ));
8188 assert!(shell_looks_like_structured_host_inspection(
8189 "dsregcmd /status"
8190 ));
8191 assert!(shell_looks_like_structured_host_inspection(
8192 "Get-Service TokenBroker,wlidsvc,OneAuth"
8193 ));
8194 assert!(shell_looks_like_structured_host_inspection(
8195 "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
8196 ));
8197 assert!(shell_looks_like_structured_host_inspection(
8198 "host github.com"
8199 ));
8200 assert!(shell_looks_like_structured_host_inspection(
8201 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
8202 ));
8203 }
8204
8205 #[test]
8206 fn dns_shell_target_extraction_handles_common_lookup_forms() {
8207 assert_eq!(
8208 extract_dns_lookup_target_from_shell("host github.com").as_deref(),
8209 Some("github.com")
8210 );
8211 assert_eq!(
8212 extract_dns_lookup_target_from_shell(
8213 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
8214 )
8215 .as_deref(),
8216 Some("github.com")
8217 );
8218 assert_eq!(
8219 extract_dns_lookup_target_from_shell(
8220 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
8221 )
8222 .as_deref(),
8223 Some("github.com")
8224 );
8225 }
8226
8227 #[test]
8228 fn dns_prompt_target_extraction_handles_plain_english_questions() {
8229 assert_eq!(
8230 extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
8231 Some("github.com")
8232 );
8233 assert_eq!(
8234 extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
8235 Some("google.com")
8236 );
8237 }
8238
8239 #[test]
8240 fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
8241 assert_eq!(
8242 extract_dns_record_type_from_text("Show me the A record for github.com"),
8243 Some("A")
8244 );
8245 assert_eq!(
8246 extract_dns_record_type_from_text("What is the IP address of google.com"),
8247 Some("A")
8248 );
8249 assert_eq!(
8250 extract_dns_record_type_from_text("Resolve the MX record for example.com"),
8251 Some("MX")
8252 );
8253 assert_eq!(
8254 extract_dns_record_type_from_shell(
8255 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
8256 ),
8257 Some("A")
8258 );
8259 assert_eq!(
8260 extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
8261 Some("MX")
8262 );
8263 }
8264
8265 #[test]
8266 fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
8267 let mut tool_name = "inspect_host".to_string();
8268 let mut args = serde_json::json!({
8269 "topic": "dns_lookup"
8270 });
8271 rewrite_host_tool_call(
8272 &mut tool_name,
8273 &mut args,
8274 Some("Show me the A record for github.com"),
8275 );
8276 assert_eq!(tool_name, "inspect_host");
8277 assert_eq!(
8278 args.get("name").and_then(|value| value.as_str()),
8279 Some("github.com")
8280 );
8281 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
8282 }
8283
8284 #[test]
8285 fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
8286 let args =
8287 host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
8288 assert_eq!(
8289 args.get("name").and_then(|value| value.as_str()),
8290 Some("google.com")
8291 );
8292 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
8293 }
8294
8295 #[test]
8296 fn host_inspection_args_from_prompt_populates_event_query_fields() {
8297 let args = host_inspection_args_from_prompt(
8298 "event_query",
8299 "Show me all System errors from the Event Log that occurred in the last 4 hours.",
8300 );
8301 assert_eq!(
8302 args.get("log").and_then(|value| value.as_str()),
8303 Some("System")
8304 );
8305 assert_eq!(
8306 args.get("level").and_then(|value| value.as_str()),
8307 Some("Error")
8308 );
8309 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
8310 }
8311
8312 #[test]
8313 fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
8314 let mut tool_name = "inspect_host".to_string();
8315 let mut args = serde_json::json!({
8316 "topic": "event_query"
8317 });
8318 rewrite_host_tool_call(
8319 &mut tool_name,
8320 &mut args,
8321 Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
8322 );
8323 assert_eq!(tool_name, "inspect_host");
8324 assert_eq!(
8325 args.get("log").and_then(|value| value.as_str()),
8326 Some("System")
8327 );
8328 assert_eq!(
8329 args.get("level").and_then(|value| value.as_str()),
8330 Some("Error")
8331 );
8332 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
8333 }
8334
8335 #[test]
8336 fn intent_router_picks_ports_for_listening_port_questions() {
8337 assert_eq!(
8338 preferred_host_inspection_topic(
8339 "Show me what is listening on port 3000 and whether anything unexpected is exposed."
8340 ),
8341 Some("ports")
8342 );
8343 }
8344
8345 #[test]
8346 fn intent_router_picks_processes_for_host_process_questions() {
8347 assert_eq!(
8348 preferred_host_inspection_topic(
8349 "Show me what processes are using the most RAM right now."
8350 ),
8351 Some("processes")
8352 );
8353 }
8354
8355 #[test]
8356 fn intent_router_picks_network_for_adapter_questions() {
8357 assert_eq!(
8358 preferred_host_inspection_topic(
8359 "Show me my active network adapters, IP addresses, gateways, and DNS servers."
8360 ),
8361 Some("network")
8362 );
8363 }
8364
8365 #[test]
8366 fn intent_router_picks_services_for_service_questions() {
8367 assert_eq!(
8368 preferred_host_inspection_topic(
8369 "Show me the running services and startup types that matter for a normal dev machine."
8370 ),
8371 Some("services")
8372 );
8373 }
8374
8375 #[test]
8376 fn intent_router_picks_env_doctor_for_package_manager_questions() {
8377 assert_eq!(
8378 preferred_host_inspection_topic(
8379 "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
8380 ),
8381 Some("env_doctor")
8382 );
8383 }
8384
8385 #[test]
8386 fn intent_router_picks_fix_plan_for_host_remediation_questions() {
8387 assert_eq!(
8388 preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
8389 Some("fix_plan")
8390 );
8391 assert_eq!(
8392 preferred_host_inspection_topic(
8393 "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
8394 ),
8395 Some("fix_plan")
8396 );
8397 }
8398
8399 #[test]
8400 fn intent_router_picks_audio_for_sound_and_microphone_questions() {
8401 assert_eq!(
8402 preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
8403 Some("audio")
8404 );
8405 assert_eq!(
8406 preferred_host_inspection_topic(
8407 "Check my microphone and playback devices because Windows Audio seems broken."
8408 ),
8409 Some("audio")
8410 );
8411 }
8412
8413 #[test]
8414 fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
8415 assert_eq!(
8416 preferred_host_inspection_topic(
8417 "Why won't this Bluetooth headset pair and stay connected?"
8418 ),
8419 Some("bluetooth")
8420 );
8421 assert_eq!(
8422 preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
8423 Some("bluetooth")
8424 );
8425 }
8426
8427 #[test]
8428 fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
8429 let mut args = serde_json::json!({
8430 "topic": "fix_plan"
8431 });
8432
8433 fill_missing_fix_plan_issue(
8434 "inspect_host",
8435 &mut args,
8436 Some("/think\nHow do I fix cargo not found on this machine?"),
8437 );
8438
8439 assert_eq!(
8440 args.get("issue").and_then(|value| value.as_str()),
8441 Some("How do I fix cargo not found on this machine?")
8442 );
8443 }
8444
8445 #[test]
8446 fn shell_fix_question_rewrites_to_fix_plan() {
8447 let args = serde_json::json!({
8448 "command": "where cargo"
8449 });
8450
8451 assert!(should_rewrite_shell_to_fix_plan(
8452 "shell",
8453 &args,
8454 Some("How do I fix cargo not found on this machine?")
8455 ));
8456 }
8457
8458 #[test]
8459 fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
8460 let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
8461 let shell_key = normalized_tool_call_key_for_dedupe(
8462 "shell",
8463 r#"{"command":"where cargo"}"#,
8464 false,
8465 latest_user_prompt,
8466 );
8467 let fix_plan_key = normalized_tool_call_key_for_dedupe(
8468 "inspect_host",
8469 r#"{"topic":"fix_plan"}"#,
8470 false,
8471 latest_user_prompt,
8472 );
8473
8474 assert_eq!(shell_key, fix_plan_key);
8475 }
8476
8477 #[test]
8478 fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
8479 let (tool_name, args) = normalized_tool_call_for_execution(
8480 "shell",
8481 r#"{"command":"pwsh ./clean.ps1 -Deep -PruneDist"}"#,
8482 false,
8483 Some("Run my cleanup scripts."),
8484 );
8485
8486 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8487 assert_eq!(
8488 args.get("workflow").and_then(|value| value.as_str()),
8489 Some("clean")
8490 );
8491 assert_eq!(
8492 args.get("deep").and_then(|value| value.as_bool()),
8493 Some(true)
8494 );
8495 assert_eq!(
8496 args.get("prune_dist").and_then(|value| value.as_bool()),
8497 Some(true)
8498 );
8499 }
8500
8501 #[test]
8502 fn shell_release_script_rewrites_to_maintainer_workflow() {
8503 let (tool_name, args) = normalized_tool_call_for_execution(
8504 "shell",
8505 r#"{"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}"#,
8506 false,
8507 Some("Run the release flow."),
8508 );
8509
8510 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8511 assert_eq!(
8512 args.get("workflow").and_then(|value| value.as_str()),
8513 Some("release")
8514 );
8515 assert_eq!(
8516 args.get("version").and_then(|value| value.as_str()),
8517 Some("0.4.5")
8518 );
8519 assert_eq!(
8520 args.get("push").and_then(|value| value.as_bool()),
8521 Some(true)
8522 );
8523 }
8524
8525 #[test]
8526 fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
8527 let (tool_name, args) = normalized_tool_call_for_execution(
8528 "shell",
8529 r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8530 false,
8531 Some("Run the deep cleanup and prune old dist artifacts."),
8532 );
8533
8534 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
8535 assert_eq!(
8536 args.get("workflow").and_then(|value| value.as_str()),
8537 Some("clean")
8538 );
8539 assert_eq!(
8540 args.get("deep").and_then(|value| value.as_bool()),
8541 Some(true)
8542 );
8543 assert_eq!(
8544 args.get("prune_dist").and_then(|value| value.as_bool()),
8545 Some(true)
8546 );
8547 }
8548
8549 #[test]
8550 fn shell_cargo_test_rewrites_to_workspace_workflow() {
8551 let (tool_name, args) = normalized_tool_call_for_execution(
8552 "shell",
8553 r#"{"command":"cargo test"}"#,
8554 false,
8555 Some("Run cargo test in this project."),
8556 );
8557
8558 assert_eq!(tool_name, "run_workspace_workflow");
8559 assert_eq!(
8560 args.get("workflow").and_then(|value| value.as_str()),
8561 Some("command")
8562 );
8563 assert_eq!(
8564 args.get("command").and_then(|value| value.as_str()),
8565 Some("cargo test")
8566 );
8567 }
8568
8569 #[test]
8570 fn current_plan_execution_request_accepts_saved_plan_command() {
8571 assert!(is_current_plan_execution_request("/implement-plan"));
8572 assert!(is_current_plan_execution_request(
8573 "Implement the current plan."
8574 ));
8575 }
8576
8577 #[test]
8578 fn architect_operator_note_points_to_execute_path() {
8579 let plan = crate::tools::plan::PlanHandoff {
8580 goal: "Tighten startup workflow guidance".into(),
8581 target_files: vec!["src/runtime.rs".into()],
8582 ordered_steps: vec!["Update the startup banner".into()],
8583 verification: "cargo check --tests".into(),
8584 risks: vec![],
8585 open_questions: vec![],
8586 };
8587 let note = architect_handoff_operator_note(&plan);
8588 assert!(note.contains("`.hematite/PLAN.md`"));
8589 assert!(note.contains("/implement-plan"));
8590 assert!(note.contains("/code implement the current plan"));
8591 }
8592
8593 #[test]
8594 fn parse_task_checklist_progress_counts_checked_items() {
8595 let progress = parse_task_checklist_progress(
8596 r#"
8597- [x] Build the landing page shell
8598- [ ] Wire the responsive nav
8599* [X] Add hero section copy
8600Plain paragraph
8601"#,
8602 );
8603
8604 assert_eq!(progress.total, 3);
8605 assert_eq!(progress.completed, 2);
8606 assert_eq!(progress.remaining, 1);
8607 assert!(progress.has_open_items());
8608 }
8609
8610 #[test]
8611 fn merge_plan_allowed_paths_includes_hematite_sidecars() {
8612 let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
8613
8614 assert!(allowed.contains(&normalize_workspace_path("src/main.rs")));
8615 assert!(allowed
8616 .iter()
8617 .any(|path| path.ends_with("/.hematite/task.md")));
8618 assert!(allowed
8619 .iter()
8620 .any(|path| path.ends_with("/.hematite/plan.md")));
8621 }
8622
8623 #[test]
8624 fn continue_plan_execution_requires_progress_and_open_items() {
8625 let mut mutated = std::collections::BTreeSet::new();
8626 mutated.insert("index.html".to_string());
8627
8628 assert!(should_continue_plan_execution(
8629 1,
8630 Some(TaskChecklistProgress {
8631 total: 3,
8632 completed: 1,
8633 remaining: 2,
8634 }),
8635 Some(TaskChecklistProgress {
8636 total: 3,
8637 completed: 2,
8638 remaining: 1,
8639 }),
8640 &mutated,
8641 ));
8642
8643 assert!(!should_continue_plan_execution(
8644 1,
8645 Some(TaskChecklistProgress {
8646 total: 3,
8647 completed: 2,
8648 remaining: 1,
8649 }),
8650 Some(TaskChecklistProgress {
8651 total: 3,
8652 completed: 2,
8653 remaining: 1,
8654 }),
8655 &std::collections::BTreeSet::new(),
8656 ));
8657
8658 assert!(!should_continue_plan_execution(
8659 6,
8660 Some(TaskChecklistProgress {
8661 total: 3,
8662 completed: 2,
8663 remaining: 1,
8664 }),
8665 Some(TaskChecklistProgress {
8666 total: 3,
8667 completed: 3,
8668 remaining: 0,
8669 }),
8670 &mutated,
8671 ));
8672 }
8673
8674 #[test]
8675 fn website_validation_runs_for_website_contract_frontend_paths() {
8676 let contract = crate::agent::workspace_profile::RuntimeContract {
8677 loop_family: "website".to_string(),
8678 app_kind: "website".to_string(),
8679 framework_hint: Some("vite".to_string()),
8680 preferred_workflows: vec!["website_validate".to_string()],
8681 delivery_phases: vec!["design".to_string(), "validate".to_string()],
8682 verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
8683 quality_gates: vec!["critical routes return HTTP 200".to_string()],
8684 local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
8685 route_hints: vec!["/".to_string()],
8686 };
8687 let mutated = std::collections::BTreeSet::from([
8688 "src/pages/index.tsx".to_string(),
8689 "public/app.css".to_string(),
8690 ]);
8691 assert!(should_run_website_validation(Some(&contract), &mutated));
8692 }
8693
8694 #[test]
8695 fn website_validation_skips_non_website_contracts() {
8696 let contract = crate::agent::workspace_profile::RuntimeContract {
8697 loop_family: "service".to_string(),
8698 app_kind: "node-service".to_string(),
8699 framework_hint: Some("express".to_string()),
8700 preferred_workflows: vec!["build".to_string()],
8701 delivery_phases: vec!["define boundary".to_string()],
8702 verification_workflows: vec!["build".to_string()],
8703 quality_gates: vec!["build stays green".to_string()],
8704 local_url_hint: None,
8705 route_hints: Vec::new(),
8706 };
8707 let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
8708 assert!(!should_run_website_validation(Some(&contract), &mutated));
8709 assert!(!should_run_website_validation(None, &mutated));
8710 }
8711
8712 #[test]
8713 fn repeat_guard_exempts_structured_website_validation() {
8714 assert!(is_repeat_guard_exempt_tool_call(
8715 "run_workspace_workflow",
8716 &serde_json::json!({ "workflow": "website_validate" }),
8717 ));
8718 assert!(!is_repeat_guard_exempt_tool_call(
8719 "run_workspace_workflow",
8720 &serde_json::json!({ "workflow": "build" }),
8721 ));
8722 }
8723
8724 #[test]
8725 fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
8726 let (tool_name, args) = normalized_tool_call_for_execution(
8727 "shell",
8728 r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8729 false,
8730 Some("Run the tests in this project."),
8731 );
8732
8733 assert_eq!(tool_name, "run_workspace_workflow");
8734 assert_eq!(
8735 args.get("workflow").and_then(|value| value.as_str()),
8736 Some("test")
8737 );
8738 }
8739
8740 #[test]
8741 fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
8742 let (tool_name, _args) = normalized_tool_call_for_execution(
8743 "shell",
8744 r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
8745 false,
8746 Some("Make me a folder on my desktop named webtest2, and in that folder build a single-page website that explains the best uses of Hematite."),
8747 );
8748
8749 assert_eq!(tool_name, "shell");
8750 }
8751
8752 #[test]
8753 fn failing_path_parser_extracts_cargo_error_locations() {
8754 let output = r#"
8755BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
8756
8757error[E0412]: cannot find type `Foo` in this scope
8758 --> src/agent/conversation.rs:42:12
8759 |
876042 | field: Foo,
8761 | ^^^ not found
8762
8763error[E0308]: mismatched types
8764 --> src/tools/file_ops.rs:100:5
8765 |
8766 = note: expected `String`, found `&str`
8767"#;
8768 let paths = parse_failing_paths_from_build_output(output);
8769 assert!(
8770 paths.iter().any(|p| p.contains("conversation.rs")),
8771 "should capture conversation.rs"
8772 );
8773 assert!(
8774 paths.iter().any(|p| p.contains("file_ops.rs")),
8775 "should capture file_ops.rs"
8776 );
8777 assert_eq!(paths.len(), 2, "no duplicates");
8778 }
8779
8780 #[test]
8781 fn failing_path_parser_ignores_macro_expansions() {
8782 let output = r#"
8783 --> <macro-expansion>:1:2
8784 --> src/real/file.rs:10:5
8785"#;
8786 let paths = parse_failing_paths_from_build_output(output);
8787 assert_eq!(paths.len(), 1);
8788 assert!(paths[0].contains("file.rs"));
8789 }
8790
8791 #[test]
8792 fn intent_router_picks_updates_for_update_questions() {
8793 assert_eq!(
8794 preferred_host_inspection_topic("is my PC up to date?"),
8795 Some("updates")
8796 );
8797 assert_eq!(
8798 preferred_host_inspection_topic("are there any pending Windows updates?"),
8799 Some("updates")
8800 );
8801 assert_eq!(
8802 preferred_host_inspection_topic("check for updates on my computer"),
8803 Some("updates")
8804 );
8805 }
8806
8807 #[test]
8808 fn intent_router_picks_security_for_antivirus_questions() {
8809 assert_eq!(
8810 preferred_host_inspection_topic("is my antivirus on?"),
8811 Some("security")
8812 );
8813 assert_eq!(
8814 preferred_host_inspection_topic("is Windows Defender running?"),
8815 Some("security")
8816 );
8817 assert_eq!(
8818 preferred_host_inspection_topic("is my PC protected?"),
8819 Some("security")
8820 );
8821 }
8822
8823 #[test]
8824 fn intent_router_picks_pending_reboot_for_restart_questions() {
8825 assert_eq!(
8826 preferred_host_inspection_topic("do I need to restart my PC?"),
8827 Some("pending_reboot")
8828 );
8829 assert_eq!(
8830 preferred_host_inspection_topic("is a reboot required?"),
8831 Some("pending_reboot")
8832 );
8833 assert_eq!(
8834 preferred_host_inspection_topic("is there a pending restart waiting?"),
8835 Some("pending_reboot")
8836 );
8837 }
8838
8839 #[test]
8840 fn intent_router_picks_disk_health_for_drive_health_questions() {
8841 assert_eq!(
8842 preferred_host_inspection_topic("is my hard drive dying?"),
8843 Some("disk_health")
8844 );
8845 assert_eq!(
8846 preferred_host_inspection_topic("check the disk health and SMART status"),
8847 Some("disk_health")
8848 );
8849 assert_eq!(
8850 preferred_host_inspection_topic("is my SSD healthy?"),
8851 Some("disk_health")
8852 );
8853 }
8854
8855 #[test]
8856 fn intent_router_picks_battery_for_battery_questions() {
8857 assert_eq!(
8858 preferred_host_inspection_topic("check my battery"),
8859 Some("battery")
8860 );
8861 assert_eq!(
8862 preferred_host_inspection_topic("how is my battery life?"),
8863 Some("battery")
8864 );
8865 assert_eq!(
8866 preferred_host_inspection_topic("what is my battery wear level?"),
8867 Some("battery")
8868 );
8869 }
8870
8871 #[test]
8872 fn intent_router_picks_recent_crashes_for_bsod_questions() {
8873 assert_eq!(
8874 preferred_host_inspection_topic("why did my PC restart by itself?"),
8875 Some("recent_crashes")
8876 );
8877 assert_eq!(
8878 preferred_host_inspection_topic("did my computer BSOD recently?"),
8879 Some("recent_crashes")
8880 );
8881 assert_eq!(
8882 preferred_host_inspection_topic("show me any recent app crashes"),
8883 Some("recent_crashes")
8884 );
8885 }
8886
8887 #[test]
8888 fn intent_router_picks_scheduled_tasks_for_task_questions() {
8889 assert_eq!(
8890 preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
8891 Some("scheduled_tasks")
8892 );
8893 assert_eq!(
8894 preferred_host_inspection_topic("show me the task scheduler"),
8895 Some("scheduled_tasks")
8896 );
8897 }
8898
8899 #[test]
8900 fn intent_router_picks_dev_conflicts_for_conflict_questions() {
8901 assert_eq!(
8902 preferred_host_inspection_topic("are there any dev environment conflicts?"),
8903 Some("dev_conflicts")
8904 );
8905 assert_eq!(
8906 preferred_host_inspection_topic("why is python pointing to the wrong version?"),
8907 Some("dev_conflicts")
8908 );
8909 }
8910
8911 #[test]
8912 fn shell_guard_catches_windows_update_commands() {
8913 assert!(shell_looks_like_structured_host_inspection(
8914 "Get-WindowsUpdateLog | Select-Object -Last 50"
8915 ));
8916 assert!(shell_looks_like_structured_host_inspection(
8917 "$sess = New-Object -ComObject Microsoft.Update.Session"
8918 ));
8919 assert!(shell_looks_like_structured_host_inspection(
8920 "Get-Service wuauserv"
8921 ));
8922 assert!(shell_looks_like_structured_host_inspection(
8923 "Get-MpComputerStatus"
8924 ));
8925 assert!(shell_looks_like_structured_host_inspection(
8926 "Get-PhysicalDisk"
8927 ));
8928 assert!(shell_looks_like_structured_host_inspection(
8929 "Get-CimInstance Win32_Battery"
8930 ));
8931 assert!(shell_looks_like_structured_host_inspection(
8932 "Get-WinEvent -FilterHashtable @{Id=41}"
8933 ));
8934 assert!(shell_looks_like_structured_host_inspection(
8935 "Get-ScheduledTask | Where-Object State -ne Disabled"
8936 ));
8937 }
8938
8939 #[test]
8940 fn intent_router_picks_permissions_for_acl_questions() {
8941 assert_eq!(
8942 preferred_host_inspection_topic("who has permission to access the downloads folder?"),
8943 Some("permissions")
8944 );
8945 assert_eq!(
8946 preferred_host_inspection_topic("audit the ntfs permissions for this path"),
8947 Some("permissions")
8948 );
8949 }
8950
8951 #[test]
8952 fn intent_router_picks_login_history_for_logon_questions() {
8953 assert_eq!(
8954 preferred_host_inspection_topic("who logged in recently on this machine?"),
8955 Some("login_history")
8956 );
8957 assert_eq!(
8958 preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
8959 Some("login_history")
8960 );
8961 }
8962
8963 #[test]
8964 fn intent_router_picks_share_access_for_unc_questions() {
8965 assert_eq!(
8966 preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
8967 Some("share_access")
8968 );
8969 assert_eq!(
8970 preferred_host_inspection_topic("test accessibility of a network share"),
8971 Some("share_access")
8972 );
8973 }
8974
8975 #[test]
8976 fn intent_router_picks_registry_audit_for_persistence_questions() {
8977 assert_eq!(
8978 preferred_host_inspection_topic(
8979 "audit my registry for persistence hacks or debugger hijacking"
8980 ),
8981 Some("registry_audit")
8982 );
8983 assert_eq!(
8984 preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
8985 Some("registry_audit")
8986 );
8987 }
8988
8989 #[test]
8990 fn intent_router_picks_network_stats_for_mbps_questions() {
8991 assert_eq!(
8992 preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
8993 Some("network_stats")
8994 );
8995 }
8996
8997 #[test]
8998 fn intent_router_picks_processes_for_cpu_percentage_questions() {
8999 assert_eq!(
9000 preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
9001 Some("processes")
9002 );
9003 }
9004
9005 #[test]
9006 fn intent_router_picks_log_check_for_recent_window_questions() {
9007 assert_eq!(
9008 preferred_host_inspection_topic("show me system errors from the last 2 hours"),
9009 Some("log_check")
9010 );
9011 }
9012
9013 #[test]
9014 fn intent_router_picks_battery_for_health_and_cycles() {
9015 assert_eq!(
9016 preferred_host_inspection_topic("check my battery health and cycle count"),
9017 Some("battery")
9018 );
9019 }
9020
9021 #[test]
9022 fn intent_router_picks_thermal_for_throttling_questions() {
9023 assert_eq!(
9024 preferred_host_inspection_topic(
9025 "why is my laptop slow? check for overheating or throttling"
9026 ),
9027 Some("thermal")
9028 );
9029 assert_eq!(
9030 preferred_host_inspection_topic("show me the current cpu temp"),
9031 Some("thermal")
9032 );
9033 }
9034
9035 #[test]
9036 fn intent_router_picks_activation_for_genuine_questions() {
9037 assert_eq!(
9038 preferred_host_inspection_topic("is my windows genuine? check activation status"),
9039 Some("activation")
9040 );
9041 assert_eq!(
9042 preferred_host_inspection_topic("run slmgr to check my license state"),
9043 Some("activation")
9044 );
9045 }
9046
9047 #[test]
9048 fn intent_router_picks_patch_history_for_hotfix_questions() {
9049 assert_eq!(
9050 preferred_host_inspection_topic("show me the recently installed hotfixes"),
9051 Some("patch_history")
9052 );
9053 assert_eq!(
9054 preferred_host_inspection_topic(
9055 "list the windows update patch history for the last 48 hours"
9056 ),
9057 Some("patch_history")
9058 );
9059 }
9060
9061 #[test]
9062 fn intent_router_detects_multiple_symptoms_for_prerun() {
9063 let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
9064 assert!(topics.contains(&"thermal"));
9065 assert!(topics.contains(&"resource_load"));
9066 assert!(topics.contains(&"storage"));
9067 assert!(topics.len() >= 3);
9068 }
9069}