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::InferenceEngine;
16use crate::agent::policy::{
17 action_target_path, docs_edit_without_explicit_request, is_destructive_tool,
18 is_mcp_mutating_tool, is_mcp_workspace_read_tool, is_sovereign_path_request,
19 normalize_workspace_path,
20};
21use crate::agent::recovery_recipes::{
22 attempt_recovery, plan_recovery, preview_recovery_decision, RecoveryContext, RecoveryDecision,
23 RecoveryPlan, RecoveryScenario, RecoveryStep,
24};
25use crate::agent::routing::{
26 all_host_inspection_topics, classify_query_intent, is_capability_probe_tool,
27 is_scaffold_request, looks_like_mutation_request, needs_computation_sandbox, needs_github_ops,
28 preferred_host_inspection_topic, preferred_maintainer_workflow, preferred_workspace_workflow,
29 DirectAnswerKind, QueryIntentClass,
30};
31use crate::agent::tool_registry::dispatch_builtin_tool;
32use crate::agent::types::{
33 ChatMessage, InferenceEvent, MessageContent, OperatorCheckpointState, ProviderRuntimeState,
34 ToolCallFn, ToolDefinition, ToolFunction,
35};
36use crate::agent::compaction::{self, CompactionConfig};
38use crate::ui::gpu_monitor::GpuState;
39
40use serde_json::Value;
41use std::sync::Arc;
42use tokio::sync::{mpsc, Mutex};
43#[derive(Clone, Debug, Default)]
46pub struct UserTurn {
47 pub text: String,
48 pub attached_document: Option<AttachedDocument>,
49 pub attached_image: Option<AttachedImage>,
50}
51
52#[derive(Clone, Debug)]
53pub struct AttachedDocument {
54 pub name: String,
55 pub content: String,
56}
57
58#[derive(Clone, Debug)]
59pub struct AttachedImage {
60 pub name: String,
61 pub path: String,
62}
63
64impl UserTurn {
65 pub fn text(text: impl Into<String>) -> Self {
66 Self {
67 text: text.into(),
68 attached_document: None,
69 attached_image: None,
70 }
71 }
72}
73
74#[derive(serde::Serialize, serde::Deserialize)]
75struct SavedSession {
76 running_summary: Option<String>,
77 #[serde(default)]
78 session_memory: crate::agent::compaction::SessionMemory,
79 #[serde(default)]
81 last_goal: Option<String>,
82 #[serde(default)]
84 turn_count: u32,
85}
86
87impl Default for SavedSession {
88 fn default() -> Self {
89 Self {
90 running_summary: None,
91 session_memory: crate::agent::compaction::SessionMemory::default(),
92 last_goal: None,
93 turn_count: 0,
94 }
95 }
96}
97
98pub struct CheckpointResume {
101 pub last_goal: String,
102 pub turn_count: u32,
103 pub working_files: Vec<String>,
104 pub last_verify_ok: Option<bool>,
105}
106
107pub fn load_checkpoint() -> Option<CheckpointResume> {
110 let path = session_path();
111 let data = std::fs::read_to_string(&path).ok()?;
112 let saved: SavedSession = serde_json::from_str(&data).ok()?;
113 let goal = saved.last_goal.filter(|g| !g.trim().is_empty())?;
114 if saved.turn_count == 0 {
115 return None;
116 }
117 let mut working_files: Vec<String> = saved
118 .session_memory
119 .working_set
120 .into_iter()
121 .take(4)
122 .collect();
123 working_files.sort();
124 let last_verify_ok = saved.session_memory.last_verification.map(|v| v.successful);
125 Some(CheckpointResume {
126 last_goal: goal,
127 turn_count: saved.turn_count,
128 working_files,
129 last_verify_ok,
130 })
131}
132
133#[derive(Default)]
134struct ActionGroundingState {
135 turn_index: u64,
136 observed_paths: std::collections::HashMap<String, u64>,
137 inspected_paths: std::collections::HashMap<String, u64>,
138 last_verify_build_turn: Option<u64>,
139 last_verify_build_ok: bool,
140 last_failed_build_paths: Vec<String>,
141 code_changed_since_verify: bool,
142 redirected_host_inspection_topics: std::collections::HashMap<String, u64>,
144}
145
146struct PlanExecutionGuard {
147 flag: Arc<std::sync::atomic::AtomicBool>,
148}
149
150impl Drop for PlanExecutionGuard {
151 fn drop(&mut self) {
152 self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
153 }
154}
155
156struct PlanExecutionPassGuard {
157 depth: Arc<std::sync::atomic::AtomicUsize>,
158}
159
160impl Drop for PlanExecutionPassGuard {
161 fn drop(&mut self) {
162 self.depth.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub enum WorkflowMode {
168 #[default]
169 Auto,
170 Ask,
171 Code,
172 Architect,
173 ReadOnly,
174 Chat,
177 Teach,
181}
182
183impl WorkflowMode {
184 fn label(self) -> &'static str {
185 match self {
186 WorkflowMode::Auto => "AUTO",
187 WorkflowMode::Ask => "ASK",
188 WorkflowMode::Code => "CODE",
189 WorkflowMode::Architect => "ARCHITECT",
190 WorkflowMode::ReadOnly => "READ-ONLY",
191 WorkflowMode::Chat => "CHAT",
192 WorkflowMode::Teach => "TEACH",
193 }
194 }
195
196 fn is_read_only(self) -> bool {
197 matches!(
198 self,
199 WorkflowMode::Ask
200 | WorkflowMode::Architect
201 | WorkflowMode::ReadOnly
202 | WorkflowMode::Teach
203 )
204 }
205
206 pub(crate) fn is_chat(self) -> bool {
207 matches!(self, WorkflowMode::Chat)
208 }
209}
210
211fn session_path() -> std::path::PathBuf {
212 if let Ok(overridden) = std::env::var("HEMATITE_SESSION_PATH") {
213 return std::path::PathBuf::from(overridden);
214 }
215 crate::tools::file_ops::hematite_dir().join("session.json")
216}
217
218fn load_session_data() -> SavedSession {
219 let path = session_path();
220 if !path.exists() {
221 let mut saved = SavedSession::default();
222 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
223 saved.session_memory.current_plan = Some(plan);
224 }
225 return saved;
226 }
227 let data = std::fs::read_to_string(&path);
228 let saved = data
229 .ok()
230 .and_then(|d| serde_json::from_str::<SavedSession>(&d).ok())
231 .unwrap_or_default();
232
233 let mut saved = saved;
234 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
235 saved.session_memory.current_plan = Some(plan);
236 }
237 saved
238}
239
240#[derive(Clone)]
241struct SovereignTeleportHandoff {
242 root: String,
243 plan: crate::tools::plan::PlanHandoff,
244}
245
246fn reset_task_files() {
247 let hdir = crate::tools::file_ops::hematite_dir();
248 let root = crate::tools::file_ops::workspace_root();
249 let _ = std::fs::remove_file(hdir.join("TASK.md"));
250 let _ = std::fs::remove_file(hdir.join("PLAN.md"));
251 let _ = std::fs::remove_file(hdir.join("WALKTHROUGH.md"));
252 let _ = std::fs::remove_file(root.join(".github").join("WALKTHROUGH.md"));
253 let _ = std::fs::write(hdir.join("TASK.md"), "");
254 let _ = std::fs::write(hdir.join("PLAN.md"), "");
255}
256
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
258struct TaskChecklistProgress {
259 total: usize,
260 completed: usize,
261 remaining: usize,
262}
263
264impl TaskChecklistProgress {
265 fn has_open_items(self) -> bool {
266 self.remaining > 0
267 }
268}
269
270fn task_status_path() -> std::path::PathBuf {
271 crate::tools::file_ops::hematite_dir().join("TASK.md")
272}
273
274fn parse_task_checklist_progress(input: &str) -> TaskChecklistProgress {
275 let mut progress = TaskChecklistProgress::default();
276
277 for line in input.lines() {
278 let trimmed = line.trim_start();
279 let candidate = trimmed
280 .strip_prefix("- ")
281 .or_else(|| trimmed.strip_prefix("* "))
282 .or_else(|| trimmed.strip_prefix("+ "))
283 .unwrap_or(trimmed);
284
285 let state = if candidate.starts_with("[x]") || candidate.starts_with("[X]") {
286 Some(true)
287 } else if candidate.starts_with("[ ]") {
288 Some(false)
289 } else {
290 None
291 };
292
293 if let Some(completed) = state {
294 progress.total += 1;
295 if completed {
296 progress.completed += 1;
297 }
298 }
299 }
300
301 progress.remaining = progress.total.saturating_sub(progress.completed);
302 progress
303}
304
305fn read_task_checklist_progress() -> Option<TaskChecklistProgress> {
306 let content = std::fs::read_to_string(task_status_path()).ok()?;
307 Some(parse_task_checklist_progress(&content))
308}
309
310fn plan_execution_sidecar_paths() -> Vec<String> {
311 let hdir = crate::tools::file_ops::hematite_dir();
312 ["TASK.md", "PLAN.md", "WALKTHROUGH.md"]
313 .iter()
314 .map(|name| normalize_workspace_path(hdir.join(name).to_string_lossy().as_ref()))
315 .collect()
316}
317
318fn merge_plan_allowed_paths(target_files: &[String]) -> Vec<String> {
319 let mut allowed = std::collections::BTreeSet::new();
320 for path in target_files {
321 allowed.insert(normalize_workspace_path(path));
322 }
323 for path in plan_execution_sidecar_paths() {
324 allowed.insert(path);
325 }
326 allowed.into_iter().collect()
327}
328
329fn should_continue_plan_execution(
330 current_pass: usize,
331 before: Option<TaskChecklistProgress>,
332 after: Option<TaskChecklistProgress>,
333 mutated_paths: &std::collections::BTreeSet<String>,
334) -> bool {
335 const MAX_AUTONOMOUS_PLAN_PASSES: usize = 6;
336
337 if current_pass >= MAX_AUTONOMOUS_PLAN_PASSES {
338 return false;
339 }
340
341 let Some(after) = after else {
342 return false;
343 };
344 if !after.has_open_items() {
345 return false;
346 }
347
348 match before {
349 Some(before) if before.total > 0 => {
350 after.completed > before.completed || after.remaining < before.remaining
351 }
352 Some(before) => after.total > before.total || !mutated_paths.is_empty(),
353 None => !mutated_paths.is_empty(),
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
358struct AutoVerificationOutcome {
359 ok: bool,
360 summary: String,
361}
362
363fn should_run_website_validation(
364 contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
365 mutated_paths: &std::collections::BTreeSet<String>,
366) -> bool {
367 let Some(contract) = contract else {
368 return false;
369 };
370 if contract.loop_family != "website" {
371 return false;
372 }
373 if mutated_paths.is_empty() {
374 return true;
375 }
376 mutated_paths.iter().any(|path| {
377 let normalized = path.replace('\\', "/").to_ascii_lowercase();
378 normalized.ends_with(".html")
379 || normalized.ends_with(".css")
380 || normalized.ends_with(".js")
381 || normalized.ends_with(".jsx")
382 || normalized.ends_with(".ts")
383 || normalized.ends_with(".tsx")
384 || normalized.ends_with(".mdx")
385 || normalized.ends_with(".vue")
386 || normalized.ends_with(".svelte")
387 || normalized.ends_with("package.json")
388 || normalized.starts_with("public/")
389 || normalized.starts_with("static/")
390 || normalized.starts_with("pages/")
391 || normalized.starts_with("app/")
392 || normalized.starts_with("src/pages/")
393 || normalized.starts_with("src/app/")
394 })
395}
396
397fn is_repeat_guard_exempt_tool_call(tool_name: &str, args: &Value) -> bool {
398 if matches!(tool_name, "verify_build" | "git_commit" | "git_push") {
399 return true;
400 }
401 tool_name == "run_workspace_workflow"
402 && matches!(
403 args.get("workflow").and_then(|value| value.as_str()),
404 Some("website_probe" | "website_validate" | "website_status")
405 )
406}
407
408fn should_run_contract_verification_workflow(
409 contract: Option<&crate::agent::workspace_profile::RuntimeContract>,
410 workflow: &str,
411 mutated_paths: &std::collections::BTreeSet<String>,
412) -> bool {
413 if matches!(workflow, "build" | "test" | "lint") {
415 return true;
416 }
417
418 match workflow {
419 "website_validate" => should_run_website_validation(contract, mutated_paths),
420 _ => true,
421 }
422}
423
424fn build_continue_plan_execution_prompt(progress: TaskChecklistProgress) -> String {
425 format!(
426 "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.",
427 progress.remaining
428 )
429}
430
431fn build_force_plan_mutation_prompt(
432 progress: TaskChecklistProgress,
433 target_files: &[String],
434) -> String {
435 let targets = if target_files.is_empty() {
436 "the saved target files".to_string()
437 } else {
438 target_files
439 .iter()
440 .map(|path| format!("`{path}`"))
441 .collect::<Vec<_>>()
442 .join(", ")
443 };
444 format!(
445 "You completed an implementation pass without mutating any target files, but `.hematite/TASK.md` still has {} unchecked item(s). This is not done. Read `.hematite/TASK.md`, inspect {}, and make a concrete implementation edit now. Do not summarize. If you still cannot mutate safely after grounding yourself in those files, surface exactly one concrete blocker.",
446 progress.remaining, targets
447 )
448}
449
450fn build_current_plan_scope_recovery_prompt(target_files: &[String]) -> String {
451 let targets = if target_files.is_empty() {
452 "the saved target files".to_string()
453 } else {
454 target_files
455 .iter()
456 .map(|path| format!("`{path}`"))
457 .collect::<Vec<_>>()
458 .join(", ")
459 };
460 format!(
461 "STOP. You just tried to read or inspect something outside the saved current-plan targets. Stay inside {} only. Read `.hematite/TASK.md` or inspect one saved target file, then make progress there. Do not branch into unrelated files or docs/exec-plans paths.",
462 targets
463 )
464}
465
466fn build_task_ledger_closeout_prompt(
467 progress: TaskChecklistProgress,
468 target_files: &[String],
469) -> String {
470 let targets = if target_files.is_empty() {
471 "the saved target files".to_string()
472 } else {
473 target_files
474 .iter()
475 .map(|path| format!("`{path}`"))
476 .collect::<Vec<_>>()
477 .join(", ")
478 };
479 format!(
480 "The deliverable files were already mutated, but `.hematite/TASK.md` still has {} unchecked item(s). This is not summary time yet. Read `.hematite/TASK.md`, verify the completed work in {}, then update the checklist to mark the finished items `[x]`. If needed, also write `.hematite/WALKTHROUGH.md`. Do not summarize until the task ledger reflects reality.",
481 progress.remaining, targets
482 )
483}
484
485fn should_suppress_recoverable_tool_result(
486 blocked_by_policy: bool,
487 recoverable_policy_intervention: bool,
488) -> bool {
489 blocked_by_policy && recoverable_policy_intervention
490}
491
492fn is_sovereign_scaffold_plan(plan: &crate::tools::plan::PlanHandoff) -> bool {
493 plan.goal
494 .to_ascii_lowercase()
495 .contains("sovereign scaffold task")
496}
497
498fn target_files_materialized(target_files: &[String]) -> bool {
499 if target_files.is_empty() {
500 return false;
501 }
502 target_files.iter().all(|path| {
503 let file = std::path::Path::new(path);
504 std::fs::metadata(file)
505 .map(|meta| meta.is_file() && meta.len() > 0)
506 .unwrap_or(false)
507 })
508}
509
510fn mark_all_task_ledger_items_complete() -> Result<TaskChecklistProgress, String> {
511 let path = task_status_path();
512 let content = std::fs::read_to_string(&path)
513 .map_err(|e| format!("Failed to read task ledger for closeout: {e}"))?;
514 let mut updated = String::new();
515 for line in content.lines() {
516 let trimmed = line.trim_start();
517 if trimmed.starts_with("- [ ]") {
518 let indent_len = line.len().saturating_sub(trimmed.len());
519 let indent = &line[..indent_len];
520 updated.push_str(indent);
521 updated.push_str(&line[indent_len..].replacen("- [ ]", "- [x]", 1));
522 } else if trimmed.starts_with("* [ ]") {
523 let indent_len = line.len().saturating_sub(trimmed.len());
524 let indent = &line[..indent_len];
525 updated.push_str(indent);
526 updated.push_str(&line[indent_len..].replacen("* [ ]", "* [x]", 1));
527 } else if trimmed.starts_with("+ [ ]") {
528 let indent_len = line.len().saturating_sub(trimmed.len());
529 let indent = &line[..indent_len];
530 updated.push_str(indent);
531 updated.push_str(&line[indent_len..].replacen("+ [ ]", "+ [x]", 1));
532 } else {
533 updated.push_str(line);
534 }
535 updated.push('\n');
536 }
537 std::fs::write(&path, updated)
538 .map_err(|e| format!("Failed to update task ledger during closeout: {e}"))?;
539 read_task_checklist_progress().ok_or_else(|| "Task ledger closeout re-read failed.".to_string())
540}
541
542fn write_minimal_walkthrough(summary: &str) -> Result<(), String> {
543 let path = crate::tools::file_ops::hematite_dir().join("WALKTHROUGH.md");
544 std::fs::write(&path, summary)
545 .map_err(|e| format!("Failed to write walkthrough during closeout: {e}"))
546}
547
548fn deterministic_sovereign_closeout_summary(
549 plan: &crate::tools::plan::PlanHandoff,
550 target_files: &[String],
551) -> String {
552 let targets = target_files
553 .iter()
554 .map(|path| format!("`{path}`"))
555 .collect::<Vec<_>>()
556 .join(", ");
557 format!(
558 "## Summary: Sovereign Scaffold Task Complete\n\n### What Was Built\nImplemented the sovereign scaffold deliverable in {}.\n\n### What Was Verified\n- Deliverable files exist and are non-empty\n- `.hematite/TASK.md` was updated to reflect completion\n- `.hematite/WALKTHROUGH.md` was written for session closeout\n\n### Plan Goal\n{}\n",
559 targets,
560 plan.goal.trim()
561 )
562}
563
564fn maybe_deterministic_sovereign_closeout(
565 plan: Option<&crate::tools::plan::PlanHandoff>,
566 mutation_occurred: bool,
567) -> Option<String> {
568 let plan = plan?;
569 if !mutation_occurred || !is_sovereign_scaffold_plan(plan) {
570 return None;
571 }
572 if !target_files_materialized(&plan.target_files) {
573 return None;
574 }
575 let progress = mark_all_task_ledger_items_complete().ok()?;
576 if progress.remaining != 0 {
577 return None;
578 }
579 let summary = deterministic_sovereign_closeout_summary(plan, &plan.target_files);
580 let _ = write_minimal_walkthrough(&summary);
581 Some(summary)
582}
583
584fn purge_persistent_memory() {
585 let mem_dir = crate::tools::file_ops::hematite_dir().join("memories");
586 if mem_dir.exists() {
587 let _ = std::fs::remove_dir_all(&mem_dir);
588 let _ = std::fs::create_dir_all(&mem_dir);
589 }
590
591 let log_dir = crate::tools::file_ops::hematite_dir().join("logs");
592 if log_dir.exists() {
593 if let Ok(entries) = std::fs::read_dir(&log_dir) {
594 for entry in entries.flatten() {
595 let _ = std::fs::write(entry.path(), "");
596 }
597 }
598 }
599}
600
601fn apply_turn_attachments(user_turn: &UserTurn, prompt: &str) -> String {
602 let mut out = prompt.trim().to_string();
603 if let Some(doc) = user_turn.attached_document.as_ref() {
604 out = format!(
605 "[Attached document: {}]\n\n{}\n\n---\n\n{}",
606 doc.name, doc.content, out
607 );
608 }
609 if let Some(image) = user_turn.attached_image.as_ref() {
610 out = if out.is_empty() {
611 format!("[Attached image: {}]", image.name)
612 } else {
613 format!("[Attached image: {}]\n\n{}", image.name, out)
614 };
615 }
616 out = inject_at_file_mentions(&out);
619 out
620}
621
622fn inject_at_file_mentions(prompt: &str) -> String {
626 if !prompt.contains('@') {
628 return prompt.to_string();
629 }
630 let cwd = match std::env::current_dir() {
631 Ok(d) => d,
632 Err(_) => return prompt.to_string(),
633 };
634
635 let mut injected = Vec::new();
636 for token in prompt.split_whitespace() {
638 let raw = token.trim_start_matches('@');
639 if !token.starts_with('@') || raw.is_empty() {
640 continue;
641 }
642 let path_str =
644 raw.trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
645 if path_str.is_empty() {
646 continue;
647 }
648 let candidate = cwd.join(path_str);
649 if candidate.is_file() {
650 match std::fs::read_to_string(&candidate) {
651 Ok(content) if !content.is_empty() => {
652 const CAP: usize = 32 * 1024;
654 let body = if content.len() > CAP {
655 format!(
656 "{}\n... [truncated — file is large, use read_file for the rest]",
657 &content[..CAP]
658 )
659 } else {
660 content
661 };
662 injected.push(format!("[File: {}]\n```\n{}\n```", path_str, body.trim()));
663 }
664 _ => {}
665 }
666 }
667 }
668
669 if injected.is_empty() {
670 return prompt.to_string();
671 }
672 format!("{}\n\n---\n\n{}", injected.join("\n\n"), prompt)
674}
675
676fn compact_stale_reads(history: &mut Vec<ChatMessage>, path: &str) {
683 const MIN_SIZE_TO_COMPACT: usize = 800;
684 let stub = "[prior read_file content compacted — file was edited; use read_file to reload]";
685 let normalized = normalize_workspace_path(path);
686 let safe_tail = history.len().saturating_sub(2);
687 for msg in history[..safe_tail].iter_mut() {
688 if msg.role != "tool" {
689 continue;
690 }
691 let is_read_tool = matches!(
692 msg.name.as_deref(),
693 Some("read_file") | Some("inspect_lines")
694 );
695 if !is_read_tool {
696 continue;
697 }
698 let content = match &msg.content {
699 crate::agent::inference::MessageContent::Text(s) => s.clone(),
700 _ => continue,
701 };
702 if content.len() < MIN_SIZE_TO_COMPACT {
703 continue;
704 }
705 if content.contains(&normalized) || content.contains(path) {
707 msg.content = crate::agent::inference::MessageContent::Text(stub.to_string());
708 }
709 }
710}
711
712fn read_file_preview_for_retry(path: &str, max_lines: usize) -> String {
715 let content = match std::fs::read_to_string(path) {
716 Ok(c) => c.replace("\r\n", "\n"),
717 Err(e) => return format!("[could not read {path}: {e}]"),
718 };
719 let total = content.lines().count();
720 let lines: String = content
721 .lines()
722 .enumerate()
723 .take(max_lines)
724 .map(|(i, line)| format!("{:>4} {}", i + 1, line))
725 .collect::<Vec<_>>()
726 .join("\n");
727 if total > max_lines {
728 format!(
729 "{lines}\n... [{} more lines — use inspect_lines to see the rest]",
730 total - max_lines
731 )
732 } else {
733 lines
734 }
735}
736
737fn transcript_user_turn_text(user_turn: &UserTurn, prompt: &str) -> String {
738 let mut prefixes = Vec::new();
739 if let Some(doc) = user_turn.attached_document.as_ref() {
740 prefixes.push(format!("[Attached document: {}]", doc.name));
741 }
742 if let Some(image) = user_turn.attached_image.as_ref() {
743 prefixes.push(format!("[Attached image: {}]", image.name));
744 }
745 if prefixes.is_empty() {
746 prompt.to_string()
747 } else if prompt.trim().is_empty() {
748 prefixes.join("\n")
749 } else {
750 format!("{}\n{}", prefixes.join("\n"), prompt)
751 }
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755enum RuntimeFailureClass {
756 ContextWindow,
757 ProviderDegraded,
758 ToolArgMalformed,
759 ToolPolicyBlocked,
760 ToolLoop,
761 VerificationFailed,
762 EmptyModelResponse,
763 Unknown,
764}
765
766impl RuntimeFailureClass {
767 fn tag(self) -> &'static str {
768 match self {
769 RuntimeFailureClass::ContextWindow => "context_window",
770 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
771 RuntimeFailureClass::ToolArgMalformed => "tool_arg_malformed",
772 RuntimeFailureClass::ToolPolicyBlocked => "tool_policy_blocked",
773 RuntimeFailureClass::ToolLoop => "tool_loop",
774 RuntimeFailureClass::VerificationFailed => "verification_failed",
775 RuntimeFailureClass::EmptyModelResponse => "empty_model_response",
776 RuntimeFailureClass::Unknown => "unknown",
777 }
778 }
779
780 fn operator_guidance(self) -> &'static str {
781 match self {
782 RuntimeFailureClass::ContextWindow => {
783 "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."
784 }
785 RuntimeFailureClass::ProviderDegraded => {
786 "Retry once automatically, then narrow the turn or restart LM Studio if it persists."
787 }
788 RuntimeFailureClass::ToolArgMalformed => {
789 "Retry with repaired or narrower tool arguments instead of repeating the same malformed call."
790 }
791 RuntimeFailureClass::ToolPolicyBlocked => {
792 "Stay inside the allowed workflow or switch modes before retrying."
793 }
794 RuntimeFailureClass::ToolLoop => {
795 "Stop repeating the same failing tool pattern and switch to a narrower recovery step."
796 }
797 RuntimeFailureClass::VerificationFailed => {
798 "Fix the build or test failure before treating the task as complete."
799 }
800 RuntimeFailureClass::EmptyModelResponse => {
801 "Retry once automatically, then narrow the turn or restart LM Studio if the model keeps returning nothing."
802 }
803 RuntimeFailureClass::Unknown => {
804 "Inspect the latest grounded tool results or provider status before retrying."
805 }
806 }
807 }
808}
809
810fn classify_runtime_failure(detail: &str) -> RuntimeFailureClass {
811 let lower = detail.to_ascii_lowercase();
812 if lower.contains("context_window_blocked")
813 || lower.contains("context ceiling reached")
814 || lower.contains("exceeds the")
815 || ((lower.contains("n_keep") && lower.contains("n_ctx"))
816 || lower.contains("context length")
817 || lower.contains("keep from the initial prompt")
818 || lower.contains("prompt is greater than the context length"))
819 {
820 RuntimeFailureClass::ContextWindow
821 } else if lower.contains("empty response from model")
822 || lower.contains("model returned an empty response")
823 {
824 RuntimeFailureClass::EmptyModelResponse
825 } else if lower.contains("lm studio unreachable")
826 || lower.contains("lm studio error")
827 || lower.contains("request failed")
828 || lower.contains("response parse error")
829 || lower.contains("provider degraded")
830 {
831 RuntimeFailureClass::ProviderDegraded
832 } else if lower.contains("missing required argument")
833 || lower.contains("json repair failed")
834 || lower.contains("invalid pattern")
835 || lower.contains("invalid line range")
836 {
837 RuntimeFailureClass::ToolArgMalformed
838 } else if lower.contains("action blocked:")
839 || lower.contains("access denied")
840 || lower.contains("declined by user")
841 {
842 RuntimeFailureClass::ToolPolicyBlocked
843 } else if lower.contains("too many consecutive tool errors")
844 || lower.contains("repeated tool failures")
845 || lower.contains("stuck in a loop")
846 {
847 RuntimeFailureClass::ToolLoop
848 } else if lower.contains("build failed")
849 || lower.contains("verification failed")
850 || lower.contains("verify_build")
851 {
852 RuntimeFailureClass::VerificationFailed
853 } else {
854 RuntimeFailureClass::Unknown
855 }
856}
857
858fn format_runtime_failure(class: RuntimeFailureClass, detail: &str) -> String {
859 let trimmed = detail.trim();
860 if trimmed.starts_with("[failure:") {
861 return trimmed.to_string();
862 }
863 format!(
864 "[failure:{}] {} Detail: {}",
865 class.tag(),
866 class.operator_guidance(),
867 trimmed
868 )
869}
870
871fn is_explicit_web_search_request(input: &str) -> bool {
872 let lower = input.to_ascii_lowercase();
873 [
874 "google ",
875 "search for ",
876 "search the web",
877 "web search",
878 "look up ",
879 "lookup ",
880 ]
881 .iter()
882 .any(|needle| lower.contains(needle))
883}
884
885fn extract_explicit_web_search_query(input: &str) -> Option<String> {
886 let lower = input.to_ascii_lowercase();
887 let mut query_tail = None;
888 for needle in [
889 "search for ",
890 "google ",
891 "look up ",
892 "lookup ",
893 "search the web for ",
894 "search the web ",
895 "web search for ",
896 "web search ",
897 ] {
898 if let Some(idx) = lower.find(needle) {
899 let rest = input[idx + needle.len()..].trim();
900 if !rest.is_empty() {
901 query_tail = Some(rest);
902 break;
903 }
904 }
905 }
906
907 let mut query = query_tail?;
908 let lower_query = query.to_ascii_lowercase();
909 let mut cut = query.len();
910 for marker in [
911 " and then ",
912 " then ",
913 " and make ",
914 " then make ",
915 " and create ",
916 " then create ",
917 " and build ",
918 " then build ",
919 " and scaffold ",
920 " then scaffold ",
921 " and turn ",
922 " then turn ",
923 ] {
924 if let Some(idx) = lower_query.find(marker) {
925 cut = cut.min(idx);
926 }
927 }
928 query = query[..cut].trim();
929 let query = query
930 .trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | ',' | '.' | ':' | ';'))
931 .trim();
932 if query.is_empty() {
933 None
934 } else {
935 Some(query.to_string())
936 }
937}
938
939fn should_use_turn_scoped_investigation_mode(
940 workflow_mode: WorkflowMode,
941 primary_class: QueryIntentClass,
942) -> bool {
943 workflow_mode == WorkflowMode::Auto && primary_class == QueryIntentClass::Research
944}
945
946fn build_research_provider_fallback(results: &str) -> String {
947 format!(
948 "Local web search succeeded, but the model runtime degraded before it could synthesize a final answer. \
949Surfacing the grounded search results directly.\n\n{}",
950 cap_output(results, 2400)
951 )
952}
953
954fn provider_state_for_runtime_failure(class: RuntimeFailureClass) -> Option<ProviderRuntimeState> {
955 match class {
956 RuntimeFailureClass::ContextWindow => Some(ProviderRuntimeState::ContextWindow),
957 RuntimeFailureClass::ProviderDegraded => Some(ProviderRuntimeState::Degraded),
958 RuntimeFailureClass::EmptyModelResponse => Some(ProviderRuntimeState::EmptyResponse),
959 _ => None,
960 }
961}
962
963fn checkpoint_state_for_runtime_failure(
964 class: RuntimeFailureClass,
965) -> Option<OperatorCheckpointState> {
966 match class {
967 RuntimeFailureClass::ContextWindow => Some(OperatorCheckpointState::BlockedContextWindow),
968 RuntimeFailureClass::ToolPolicyBlocked => Some(OperatorCheckpointState::BlockedPolicy),
969 RuntimeFailureClass::ToolLoop => Some(OperatorCheckpointState::BlockedToolLoop),
970 RuntimeFailureClass::VerificationFailed => {
971 Some(OperatorCheckpointState::BlockedVerification)
972 }
973 _ => None,
974 }
975}
976
977fn compact_runtime_recovery_summary(class: RuntimeFailureClass) -> &'static str {
978 match class {
979 RuntimeFailureClass::ProviderDegraded => {
980 "LM Studio degraded during the turn; retrying once before surfacing a failure."
981 }
982 RuntimeFailureClass::EmptyModelResponse => {
983 "The model returned an empty reply; retrying once before surfacing a failure."
984 }
985 _ => "Runtime recovery in progress.",
986 }
987}
988
989fn checkpoint_summary_for_runtime_failure(class: RuntimeFailureClass) -> &'static str {
990 match class {
991 RuntimeFailureClass::ContextWindow => "Provider context ceiling confirmed.",
992 RuntimeFailureClass::ToolPolicyBlocked => "Policy blocked the current action.",
993 RuntimeFailureClass::ToolLoop => "Repeated failing tool pattern stopped.",
994 RuntimeFailureClass::VerificationFailed => "Verification failed; fix before continuing.",
995 _ => "Operator checkpoint updated.",
996 }
997}
998
999fn compact_runtime_failure_summary(class: RuntimeFailureClass) -> &'static str {
1000 match class {
1001 RuntimeFailureClass::ContextWindow => "LM context ceiling hit.",
1002 RuntimeFailureClass::ProviderDegraded => {
1003 "LM Studio degraded and did not recover cleanly; operator action is now required."
1004 }
1005 RuntimeFailureClass::EmptyModelResponse => {
1006 "LM Studio returned an empty reply after recovery; operator action is now required."
1007 }
1008 RuntimeFailureClass::ToolLoop => {
1009 "Repeated failing tool pattern detected; Hematite stopped the loop."
1010 }
1011 _ => "Runtime failure surfaced to the operator.",
1012 }
1013}
1014
1015fn should_retry_runtime_failure(class: RuntimeFailureClass) -> bool {
1016 matches!(
1017 class,
1018 RuntimeFailureClass::ProviderDegraded | RuntimeFailureClass::EmptyModelResponse
1019 )
1020}
1021
1022fn recovery_scenario_for_runtime_failure(class: RuntimeFailureClass) -> Option<RecoveryScenario> {
1023 match class {
1024 RuntimeFailureClass::ContextWindow => Some(RecoveryScenario::ContextWindow),
1025 RuntimeFailureClass::ProviderDegraded => Some(RecoveryScenario::ProviderDegraded),
1026 RuntimeFailureClass::EmptyModelResponse => Some(RecoveryScenario::EmptyModelResponse),
1027 RuntimeFailureClass::ToolPolicyBlocked => Some(RecoveryScenario::McpWorkspaceReadBlocked),
1028 RuntimeFailureClass::ToolLoop => Some(RecoveryScenario::ToolLoop),
1029 RuntimeFailureClass::VerificationFailed => Some(RecoveryScenario::VerificationFailed),
1030 RuntimeFailureClass::ToolArgMalformed | RuntimeFailureClass::Unknown => None,
1031 }
1032}
1033
1034fn compact_recovery_plan_summary(plan: &RecoveryPlan) -> String {
1035 format!(
1036 "{} [{}]",
1037 plan.recipe.scenario.label(),
1038 plan.recipe.steps_summary()
1039 )
1040}
1041
1042fn compact_recovery_decision_summary(decision: &RecoveryDecision) -> String {
1043 match decision {
1044 RecoveryDecision::Attempt(plan) => compact_recovery_plan_summary(plan),
1045 RecoveryDecision::Escalate {
1046 recipe,
1047 attempts_made,
1048 ..
1049 } => format!(
1050 "{} escalated after {} / {} [{}]",
1051 recipe.scenario.label(),
1052 attempts_made,
1053 recipe.max_attempts.max(1),
1054 recipe.steps_summary()
1055 ),
1056 }
1057}
1058
1059fn parse_failing_paths_from_build_output(output: &str) -> Vec<String> {
1062 let root = crate::tools::file_ops::workspace_root();
1063 let mut paths: Vec<String> = output
1064 .lines()
1065 .filter_map(|line| {
1066 let trimmed = line.trim_start();
1067 let after_arrow = trimmed.strip_prefix("--> ")?;
1069 let file_part = after_arrow.split(':').next()?;
1070 if file_part.is_empty() || file_part.starts_with('<') {
1071 return None;
1072 }
1073 let p = std::path::Path::new(file_part);
1074 let resolved = if p.is_absolute() {
1075 p.to_path_buf()
1076 } else {
1077 root.join(p)
1078 };
1079 Some(resolved.to_string_lossy().replace('\\', "/").to_lowercase())
1080 })
1081 .collect();
1082 paths.sort();
1083 paths.dedup();
1084 paths
1085}
1086
1087fn build_mode_redirect_answer(mode: WorkflowMode) -> String {
1088 match mode {
1089 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(),
1090 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(),
1091 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(),
1092 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(),
1093 _ => "Switch to `/code` or `/auto` to allow implementation.".to_string(),
1094 }
1095}
1096
1097fn architect_handoff_contract() -> &'static str {
1098 "ARCHITECT OUTPUT CONTRACT:\n\
1099Use a compact implementation handoff, not a process narrative.\n\
1100Do not say \"the first step\" or describe what you are about to do.\n\
1101After one or two read-only inspection tools at most, stop and answer.\n\
1102For runtime wiring, reset behavior, or control-flow questions, prefer `trace_runtime_flow`.\n\
1103Use these exact ASCII headings and keep each section short:\n\
1104# Goal\n\
1105# Target Files\n\
1106# Ordered Steps\n\
1107# Verification\n\
1108# Risks\n\
1109# Open Questions\n\
1110Keep the whole handoff concise and implementation-oriented."
1111}
1112
1113fn implement_current_plan_prompt() -> &'static str {
1114 "Implement the current plan."
1115}
1116
1117fn scaffold_protocol() -> &'static str {
1118 "\n\n# SCAFFOLD MODE — PROJECT CREATION PROTOCOL\n\
1119 The user wants a new project created. Your job is to build it completely, right now, without stopping.\n\
1120 \n\
1121 ## Autonomy rules\n\
1122 - Build every file the project needs in one pass. Do NOT stop after one file and wait.\n\
1123 - After writing each file, read it back to verify it is complete and not truncated.\n\
1124 - Check cross-file consistency before finishing.\n\
1125 - Once the project is coherent, runnable, and verified, STOP.\n\
1126 - 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\
1127 - If only optional polish remains, present it as optional next steps instead of mutating more files.\n\
1128 - Ask the user only when blocked by a real product decision, missing requirement, or risky/destructive choice.\n\
1129 - Only surface results to the user once ALL files exist and the project is immediately runnable.\n\
1130 - Final delivery must sound like a human engineer closeout: stack chosen, what was built, what was verified, and what remains optional.\n\
1131 \n\
1132 ## Infer the stack from context\n\
1133 If the user gives only a vague request (\"make me a website\", \"build me a tool\"), pick the most\n\
1134 sensible minimal stack and state your choice before creating files. Do not ask permission — choose and build.\n\
1135 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\
1136 Default choices: website → static HTML+CSS+JS; CLI tool → Rust (clap) if Rust project, Python (argparse/click) otherwise;\n\
1137 API → FastAPI (Python) or Express (Node); web app with state → React (Vite).\n\
1138 \n\
1139 ## Stack file structures\n\
1140 \n\
1141 **Static HTML site / landing page:**\n\
1142 index.html (semantic: header/nav/main/footer, doctype, meta charset/viewport, linked CSS+JS),\n\
1143 style.css (CSS variables, mobile-first, grid/flexbox, @media breakpoints, hover/focus states),\n\
1144 script.js (DOMContentLoaded guard, smooth scroll, no console.log left in), README.md\n\
1145 \n\
1146 **React (Vite):**\n\
1147 package.json (scripts: dev/build/preview, deps: react react-dom, devDeps: vite @vitejs/plugin-react),\n\
1148 vite.config.js, index.html (root div), src/main.jsx, src/App.jsx, src/App.css, src/index.css, .gitignore, README.md\n\
1149 \n\
1150 **Next.js (App Router):**\n\
1151 package.json (next react react-dom, scripts: dev/build/start),\n\
1152 next.config.js, tsconfig.json, app/layout.tsx, app/page.tsx, app/globals.css, public/.gitkeep, .gitignore, README.md\n\
1153 \n\
1154 **Vue 3 (Vite):**\n\
1155 package.json (vue, vite, @vitejs/plugin-vue),\n\
1156 vite.config.js, index.html, src/main.js, src/App.vue, src/components/.gitkeep, .gitignore, README.md\n\
1157 \n\
1158 **SvelteKit:**\n\
1159 package.json (@sveltejs/kit, svelte, vite, @sveltejs/adapter-auto),\n\
1160 svelte.config.js, vite.config.js, src/routes/+page.svelte, src/app.html, static/.gitkeep, .gitignore, README.md\n\
1161 \n\
1162 **Express.js API:**\n\
1163 package.json (express, cors, dotenv; nodemon as devDep; scripts: start/dev),\n\
1164 src/index.js (listen + middleware), src/routes/index.js, src/middleware/error.js, .env.example, .gitignore, README.md\n\
1165 \n\
1166 **FastAPI (Python):**\n\
1167 requirements.txt (fastapi, uvicorn[standard], pydantic),\n\
1168 main.py (app = FastAPI(), include_router, uvicorn.run guard),\n\
1169 app/__init__.py, app/routers/items.py, app/models.py, .gitignore (venv/ __pycache__/ .env), README.md\n\
1170 \n\
1171 **Flask (Python):**\n\
1172 requirements.txt (flask, python-dotenv),\n\
1173 app.py or app/__init__.py, app/routes.py, templates/base.html, static/style.css, .gitignore, README.md\n\
1174 \n\
1175 **Django:**\n\
1176 requirements.txt, manage.py, project/settings.py, project/urls.py, project/wsgi.py,\n\
1177 app/models.py, app/views.py, app/urls.py, templates/base.html, .gitignore, README.md\n\
1178 \n\
1179 **Python CLI (click or argparse):**\n\
1180 pyproject.toml (name, version, [project.scripts] entry point) or setup.py,\n\
1181 src/<name>/__init__.py, src/<name>/cli.py (click group or argparse main), src/<name>/core.py,\n\
1182 README.md, .gitignore (__pycache__/ dist/ *.egg-info venv/)\n\
1183 \n\
1184 **Python package/library:**\n\
1185 pyproject.toml (PEP 517/518, hatchling or setuptools), src/<name>/__init__.py, src/<name>/core.py,\n\
1186 tests/__init__.py, tests/test_core.py, README.md, .gitignore\n\
1187 \n\
1188 **Rust CLI (clap):**\n\
1189 Cargo.toml (name, edition=2021, clap with derive feature),\n\
1190 src/main.rs (Cli struct with #[derive(Parser)], fn main), src/cli.rs (subcommands if needed),\n\
1191 README.md, .gitignore (target/)\n\
1192 \n\
1193 **Rust library:**\n\
1194 Cargo.toml ([lib], edition=2021), src/lib.rs (pub mod, pub fn, doc comments),\n\
1195 tests/integration_test.rs, README.md, .gitignore\n\
1196 \n\
1197 **Go project / CLI:**\n\
1198 go.mod (module <name>, go 1.21), main.go (package main, func main),\n\
1199 cmd/<name>/main.go if CLI, internal/core/core.go for logic,\n\
1200 README.md, .gitignore (bin/ *.exe)\n\
1201 \n\
1202 **C++ project (CMake):**\n\
1203 CMakeLists.txt (cmake_minimum_required, project, add_executable, set C++17/20),\n\
1204 src/main.cpp, include/<name>.h, src/<name>.cpp,\n\
1205 README.md, .gitignore (build/ *.o *.exe CMakeCache.txt)\n\
1206 \n\
1207 **Node.js TypeScript API:**\n\
1208 package.json (express @types/express typescript ts-node nodemon; scripts: build/dev/start),\n\
1209 tsconfig.json (strict, esModuleInterop, outDir: dist), src/index.ts, src/routes/index.ts,\n\
1210 .env.example, .gitignore, README.md\n\
1211 \n\
1212 ## File quality rules\n\
1213 - Every file must be complete — no truncation, no placeholder comments like \"add logic here\"\n\
1214 - package.json: name, version, scripts, all deps explicit\n\
1215 - HTML: doctype, charset, viewport, title, all linked CSS/JS, semantic structure\n\
1216 - CSS: consistent class names matching HTML exactly, responsive, variables for colors/spacing\n\
1217 - .gitignore: cover node_modules/, dist/, .env, __pycache__/, target/, venv/, build/ as appropriate\n\
1218 - Rust Cargo.toml: edition = \"2021\", all used crates declared\n\
1219 - Go go.mod: module path and go version declared\n\
1220 - C++ CMakeLists.txt: cmake version, project name, standard, all source files listed\n\
1221 \n\
1222 ## After scaffolding — required wrap-up\n\
1223 1. List every file created with a one-line description of what it does\n\
1224 2. Give the exact command(s) to install dependencies and run the project\n\
1225 3. Tell the user they can type `/cd <project-folder>` to teleport into the new project\n\
1226 4. Ask what they'd like to work on next — offer 2-3 specific suggestions relevant to the stack\n\
1227 (e.g. \"Want me to add routing? Set up authentication? Add a dark mode toggle? Or should we improve the design?\")\n\
1228 5. Stay engaged — you are their coding partner, not a one-shot file generator\n"
1229}
1230
1231fn looks_like_static_site_request(input: &str) -> bool {
1232 let lower = input.to_ascii_lowercase();
1233 let mentions_site_shape = lower.contains("website")
1234 || lower.contains("landing page")
1235 || lower.contains("web page")
1236 || lower.contains("html website")
1237 || lower.contains("html site")
1238 || lower.contains("single index.html")
1239 || lower.contains("index.html")
1240 || lower.contains("single file html")
1241 || lower.contains("single-file html")
1242 || lower.contains("single html file");
1243 mentions_site_shape
1244 && (lower.contains("html")
1245 || lower.contains("css")
1246 || lower.contains("javascript")
1247 || lower.contains("js")
1248 || lower.contains("index.html")
1249 || !lower.contains("react"))
1250}
1251
1252fn prefers_single_file_html_site(input: &str) -> bool {
1253 let lower = input.to_ascii_lowercase();
1254 lower.contains("single index.html")
1255 || lower.contains("index.html")
1256 || lower.contains("single file html")
1257 || lower.contains("single-file html")
1258 || lower.contains("single html file")
1259}
1260
1261fn sanitize_project_folder_name(raw: &str) -> String {
1262 let trimmed = raw
1263 .trim()
1264 .trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '.' | ',' | ':' | ';'));
1265 let mut out = String::new();
1266 for ch in trimmed.chars() {
1267 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ' ') {
1268 out.push(ch);
1269 } else {
1270 out.push('_');
1271 }
1272 }
1273 let cleaned = out.trim().replace(' ', "_");
1274 if cleaned.is_empty() {
1275 "hematite_project".to_string()
1276 } else {
1277 cleaned
1278 }
1279}
1280
1281fn extract_named_folder(lower: &str) -> Option<String> {
1282 for marker in [" named ", " called "] {
1283 if let Some(idx) = lower.find(marker) {
1284 let rest = &lower[idx + marker.len()..];
1285 let name = rest
1286 .split(|c: char| {
1287 c.is_whitespace() || matches!(c, ',' | '.' | ':' | ';' | '!' | '?')
1288 })
1289 .next()
1290 .unwrap_or("")
1291 .trim();
1292 if !name.is_empty() {
1293 return Some(sanitize_project_folder_name(name));
1294 }
1295 }
1296 }
1297 None
1298}
1299
1300fn extract_sovereign_scaffold_root(user_input: &str) -> Option<std::path::PathBuf> {
1301 let lower = user_input.to_ascii_lowercase();
1302 let folder_name = extract_named_folder(&lower)?;
1303
1304 let base = if lower.contains("desktop") {
1305 dirs::desktop_dir()
1306 } else if lower.contains("download") {
1307 dirs::download_dir()
1308 } else if lower.contains("document") || lower.contains("docs") {
1309 dirs::document_dir()
1310 } else {
1311 None
1312 }?;
1313
1314 Some(base.join(folder_name))
1315}
1316
1317fn default_sovereign_scaffold_targets(user_input: &str) -> std::collections::BTreeSet<String> {
1318 let mut targets = std::collections::BTreeSet::new();
1319 if looks_like_static_site_request(user_input) {
1320 targets.insert("index.html".to_string());
1321 if !prefers_single_file_html_site(user_input) {
1322 targets.insert("style.css".to_string());
1323 targets.insert("script.js".to_string());
1324 }
1325 }
1326 targets
1327}
1328
1329fn seed_sovereign_scaffold_files(
1330 root: &std::path::Path,
1331 targets: &std::collections::BTreeSet<String>,
1332) -> Result<(), String> {
1333 for relative in targets {
1334 let path = root.join(relative);
1335 if let Some(parent) = path.parent() {
1336 std::fs::create_dir_all(parent)
1337 .map_err(|e| format!("Failed to create scaffold parent directory: {e}"))?;
1338 }
1339 if !path.exists() {
1340 std::fs::write(&path, "")
1341 .map_err(|e| format!("Failed to seed scaffold file {}: {e}", path.display()))?;
1342 }
1343 }
1344 Ok(())
1345}
1346
1347fn write_sovereign_handoff_markdown(
1348 root: &std::path::Path,
1349 user_input: &str,
1350 plan: &crate::tools::plan::PlanHandoff,
1351) -> Result<(), String> {
1352 let handoff_path = root.join("HEMATITE_HANDOFF.md");
1353 let content = format!(
1354 "# Hematite Handoff\n\n\
1355 Original request:\n\
1356 - {}\n\n\
1357 This project root was pre-created by Hematite before teleport.\n\
1358 The next session should resume from the local `.hematite/PLAN.md` handoff and continue implementation here.\n\n\
1359 ## Planned Target Files\n{}\n\
1360 ## Verification\n- {}\n",
1361 user_input.trim(),
1362 if plan.target_files.is_empty() {
1363 "- project files to be created in the resumed session\n".to_string()
1364 } else {
1365 plan.target_files
1366 .iter()
1367 .map(|path| format!("- {path}\n"))
1368 .collect::<String>()
1369 },
1370 plan.verification.trim()
1371 );
1372 std::fs::write(&handoff_path, content)
1373 .map_err(|e| format!("Failed to write handoff markdown: {e}"))
1374}
1375
1376fn build_sovereign_scaffold_handoff(
1377 user_input: &str,
1378 target_files: &std::collections::BTreeSet<String>,
1379) -> crate::tools::plan::PlanHandoff {
1380 let mut steps = vec![
1381 "Read the scaffolded files in this root before changing them so the resumed session stays grounded in the actual generated content.".to_string(),
1382 "Finish the implementation inside this sovereign project root only; do not reason from the old workspace or unrelated ./src context.".to_string(),
1383 "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(),
1384 ];
1385 if let Some(query) = extract_explicit_web_search_query(user_input) {
1386 steps.insert(
1387 1,
1388 format!(
1389 "Use `research_web` first to gather current context about `{query}` before drafting content or copy for this new project root."
1390 ),
1391 );
1392 }
1393 let verification = if looks_like_static_site_request(user_input) {
1394 if prefers_single_file_html_site(user_input) {
1395 steps.insert(
1396 1,
1397 "Keep the deliverable to a single `index.html` file with inline structure/content that explains the research clearly and reads well on desktop and mobile."
1398 .to_string(),
1399 );
1400 "Open and inspect `index.html` in this root, confirm the page is coherent, self-contained, and responsive without relying on extra front-end files or repo-root workflows.".to_string()
1401 } else {
1402 steps.insert(
1403 1,
1404 "Make sure index.html, style.css, and script.js stay linked correctly and that the layout remains responsive on desktop and mobile.".to_string(),
1405 );
1406 "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()
1407 }
1408 } else {
1409 "Use only project-appropriate verification scoped to this root. Avoid unrelated repo workflows; verify the generated files are internally consistent before stopping.".to_string()
1410 };
1411
1412 crate::tools::plan::PlanHandoff {
1413 goal: format!(
1414 "Continue the sovereign scaffold task in this new project root: {}",
1415 user_input.trim()
1416 ),
1417 target_files: target_files.iter().cloned().collect(),
1418 ordered_steps: steps,
1419 verification,
1420 risks: vec![
1421 "Do not drift back into the originating workspace or unrelated ./src context."
1422 .to_string(),
1423 "Avoid endless UI polish loops once the generated project is already coherent."
1424 .to_string(),
1425 ],
1426 open_questions: Vec::new(),
1427 }
1428}
1429
1430fn architect_handoff_operator_note(plan: &crate::tools::plan::PlanHandoff) -> String {
1431 format!(
1432 "Implementation handoff saved to `.hematite/PLAN.md`.\nNext step: run `/implement-plan` to execute it in `/code`, or use `/code {}` directly.\nPlan: {}",
1433 implement_current_plan_prompt().to_ascii_lowercase(),
1434 plan.summary_line()
1435 )
1436}
1437
1438fn is_current_plan_execution_request(user_input: &str) -> bool {
1439 let lower = user_input.trim().to_ascii_lowercase();
1440 lower == "/implement-plan"
1441 || lower == implement_current_plan_prompt().to_ascii_lowercase()
1442 || lower
1443 == implement_current_plan_prompt()
1444 .trim_end_matches('.')
1445 .to_ascii_lowercase()
1446 || lower.contains("implement the current plan")
1447}
1448
1449fn is_plan_scoped_tool(name: &str) -> bool {
1450 crate::agent::inference::tool_metadata_for_name(name).plan_scope
1451}
1452
1453fn is_current_plan_irrelevant_tool(name: &str) -> bool {
1454 !crate::agent::inference::tool_metadata_for_name(name).plan_scope
1455}
1456
1457fn is_non_mutating_plan_step_tool(name: &str) -> bool {
1458 let metadata = crate::agent::inference::tool_metadata_for_name(name);
1459 metadata.plan_scope && !metadata.mutates_workspace
1460}
1461
1462fn plan_handoff_mentions_tool(plan: &crate::tools::plan::PlanHandoff, tool_name: &str) -> bool {
1463 let needle = tool_name.to_ascii_lowercase();
1464 std::iter::once(plan.goal.as_str())
1465 .chain(plan.ordered_steps.iter().map(String::as_str))
1466 .chain(std::iter::once(plan.verification.as_str()))
1467 .chain(plan.risks.iter().map(String::as_str))
1468 .chain(plan.open_questions.iter().map(String::as_str))
1469 .any(|text| text.to_ascii_lowercase().contains(&needle))
1470}
1471
1472fn parse_inline_workflow_prompt(user_input: &str) -> Option<(WorkflowMode, &str)> {
1473 let trimmed = user_input.trim();
1474 for (prefix, mode) in [
1475 ("/ask", WorkflowMode::Ask),
1476 ("/code", WorkflowMode::Code),
1477 ("/architect", WorkflowMode::Architect),
1478 ("/read-only", WorkflowMode::ReadOnly),
1479 ("/auto", WorkflowMode::Auto),
1480 ("/teach", WorkflowMode::Teach),
1481 ] {
1482 if let Some(rest) = trimmed.strip_prefix(prefix) {
1483 let rest = rest.trim();
1484 if !rest.is_empty() {
1485 return Some((mode, rest));
1486 }
1487 }
1488 }
1489 None
1490}
1491
1492pub fn get_tools() -> Vec<ToolDefinition> {
1496 crate::agent::tool_registry::get_tools()
1497}
1498
1499fn is_natural_language_hallucination(input: &str) -> bool {
1500 let lower = input.to_lowercase();
1501 let words = lower.split_whitespace().collect::<Vec<_>>();
1502
1503 if words.is_empty() {
1505 return false;
1506 }
1507 let first = words[0];
1508 if [
1509 "make", "create", "i", "can", "please", "we", "let's", "go", "execute", "run", "how",
1510 ]
1511 .contains(&first)
1512 {
1513 if words.len() >= 3 {
1515 return true;
1516 }
1517 }
1518
1519 let stop_words = [
1521 "the", "a", "an", "on", "my", "your", "for", "with", "into", "onto",
1522 ];
1523 let stop_count = words.iter().filter(|w| stop_words.contains(w)).count();
1524 if stop_count >= 2 {
1525 return true;
1526 }
1527
1528 if words.len() >= 5
1530 && !input.contains('-')
1531 && !input.contains('/')
1532 && !input.contains('\\')
1533 && !input.contains('.')
1534 {
1535 return true;
1536 }
1537
1538 false
1539}
1540
1541pub struct ConversationManager {
1542 pub history: Vec<ChatMessage>,
1544 pub engine: Arc<InferenceEngine>,
1545 pub tools: Vec<ToolDefinition>,
1546 pub mcp_manager: Arc<Mutex<crate::agent::mcp_manager::McpManager>>,
1547 pub professional: bool,
1548 pub brief: bool,
1549 pub snark: u8,
1550 pub chaos: u8,
1551 pub fast_model: Option<String>,
1553 pub think_model: Option<String>,
1555 pub correction_hints: Vec<String>,
1557 pub running_summary: Option<String>,
1559 pub gpu_state: Arc<GpuState>,
1561 pub vein: crate::memory::vein::Vein,
1563 pub transcript: crate::agent::transcript::TranscriptLogger,
1565 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
1567 pub git_state: Arc<crate::agent::git_monitor::GitState>,
1569 pub think_mode: Option<bool>,
1572 workflow_mode: WorkflowMode,
1573 pub session_memory: crate::agent::compaction::SessionMemory,
1575 pub swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1576 pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
1577 pub soul_personality: String,
1579 pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
1580 pub reasoning_history: Option<String>,
1582 pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
1584 action_grounding: Arc<Mutex<ActionGroundingState>>,
1586 plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
1588 plan_execution_pass_depth: Arc<std::sync::atomic::AtomicUsize>,
1590 recovery_context: RecoveryContext,
1592 pub l1_context: Option<String>,
1595 pub repo_map: Option<String>,
1597 pub turn_count: u32,
1599 pub last_goal: Option<String>,
1601 pub latest_target_dir: Option<String>,
1603 pending_teleport_handoff: Option<SovereignTeleportHandoff>,
1605 pub diff_tracker: Arc<Mutex<crate::agent::diff_tracker::TurnDiffTracker>>,
1607 pub last_heartbeat: Option<crate::agent::policy::ToolchainHeartbeat>,
1609 pending_skill_inject: Option<String>,
1611 shell_history_block: Option<String>,
1613 pending_fix_context: Option<String>,
1615 last_turn_budget: Option<crate::agent::economics::TurnBudget>,
1617}
1618
1619impl ConversationManager {
1620 fn vein_docs_only_mode(&self) -> bool {
1621 !crate::tools::file_ops::is_project_workspace()
1622 }
1623
1624 fn refresh_vein_index(&mut self) -> usize {
1625 let count = if self.vein_docs_only_mode() {
1626 tokio::task::block_in_place(|| {
1627 self.vein
1628 .index_workspace_artifacts(&crate::tools::file_ops::hematite_dir())
1629 })
1630 } else {
1631 tokio::task::block_in_place(|| self.vein.index_project())
1632 };
1633 self.l1_context = self.vein.l1_context();
1634 count
1635 }
1636
1637 fn build_vein_inspection_report(&self, indexed_this_pass: usize) -> String {
1638 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(8));
1639 let workspace_mode = if self.vein_docs_only_mode() {
1640 "docs-only (outside a project workspace)"
1641 } else {
1642 "project workspace"
1643 };
1644 let active_room = snapshot.active_room.as_deref().unwrap_or("none");
1645 let mut out = format!(
1646 "Vein Inspection\n\
1647 Workspace mode: {workspace_mode}\n\
1648 Indexed this pass: {indexed_this_pass}\n\
1649 Indexed source files: {}\n\
1650 Indexed docs: {}\n\
1651 Indexed session exchanges: {}\n\
1652 Embedded source/doc chunks: {}\n\
1653 Embeddings available: {}\n\
1654 Active room bias: {active_room}\n\
1655 L1 hot-files block: {}\n",
1656 snapshot.indexed_source_files,
1657 snapshot.indexed_docs,
1658 snapshot.indexed_session_exchanges,
1659 snapshot.embedded_source_doc_chunks,
1660 if snapshot.has_any_embeddings {
1661 "yes"
1662 } else {
1663 "no"
1664 },
1665 if snapshot.l1_ready {
1666 "ready"
1667 } else {
1668 "not built yet"
1669 },
1670 );
1671
1672 if snapshot.hot_files.is_empty() {
1673 out.push_str("Hot files: none yet.\n");
1674 return out;
1675 }
1676
1677 out.push_str("\nHot files by room:\n");
1678 let mut by_room: std::collections::BTreeMap<&str, Vec<&crate::memory::vein::VeinHotFile>> =
1679 std::collections::BTreeMap::new();
1680 for file in &snapshot.hot_files {
1681 by_room.entry(file.room.as_str()).or_default().push(file);
1682 }
1683 for (room, files) in by_room {
1684 out.push_str(&format!("[{}]\n", room));
1685 for file in files {
1686 out.push_str(&format!(
1687 "- {} [{} edit{}]\n",
1688 file.path,
1689 file.heat,
1690 if file.heat == 1 { "" } else { "s" }
1691 ));
1692 }
1693 }
1694
1695 out
1696 }
1697
1698 fn latest_user_prompt(&self) -> Option<&str> {
1699 self.history
1700 .iter()
1701 .rev()
1702 .find(|msg| msg.role == "user")
1703 .map(|msg| msg.content.as_str())
1704 }
1705
1706 async fn emit_direct_response(
1707 &mut self,
1708 tx: &mpsc::Sender<InferenceEvent>,
1709 raw_user_input: &str,
1710 effective_user_input: &str,
1711 response: &str,
1712 ) {
1713 self.history.push(ChatMessage::user(effective_user_input));
1714 self.history.push(ChatMessage::assistant_text(response));
1715 self.transcript.log_user(raw_user_input);
1716 self.transcript.log_agent(response);
1717 for chunk in chunk_text(response, 8) {
1718 if !chunk.is_empty() {
1719 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1720 }
1721 }
1722 if let Some(path) = self.latest_target_dir.take() {
1723 self.persist_pending_teleport_handoff();
1724 let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
1725 }
1726 let _ = tx.send(InferenceEvent::Done).await;
1727 self.trim_history(80);
1728 self.refresh_session_memory();
1729 self.save_session();
1730 }
1731
1732 async fn emit_operator_checkpoint(
1733 &mut self,
1734 tx: &mpsc::Sender<InferenceEvent>,
1735 state: OperatorCheckpointState,
1736 summary: impl Into<String>,
1737 ) {
1738 let summary = summary.into();
1739 self.session_memory
1740 .record_checkpoint(state.label(), summary.clone());
1741 let _ = tx
1742 .send(InferenceEvent::OperatorCheckpoint { state, summary })
1743 .await;
1744 }
1745
1746 async fn emit_recovery_recipe_summary(
1747 &mut self,
1748 tx: &mpsc::Sender<InferenceEvent>,
1749 state: impl Into<String>,
1750 summary: impl Into<String>,
1751 ) {
1752 let state = state.into();
1753 let summary = summary.into();
1754 self.session_memory.record_recovery(state, summary.clone());
1755 let _ = tx.send(InferenceEvent::RecoveryRecipe { summary }).await;
1756 }
1757
1758 async fn emit_provider_live(&mut self, tx: &mpsc::Sender<InferenceEvent>) {
1759 let _ = tx
1760 .send(InferenceEvent::ProviderStatus {
1761 state: ProviderRuntimeState::Live,
1762 summary: String::new(),
1763 })
1764 .await;
1765 self.emit_operator_checkpoint(tx, OperatorCheckpointState::Idle, "")
1766 .await;
1767 }
1768
1769 async fn emit_prompt_pressure_for_messages(
1770 &self,
1771 tx: &mpsc::Sender<InferenceEvent>,
1772 messages: &[ChatMessage],
1773 ) {
1774 let context_length = self.engine.current_context_length();
1775 let (estimated_input_tokens, reserved_output_tokens, estimated_total_tokens, percent) =
1776 crate::agent::inference::estimate_prompt_pressure(
1777 messages,
1778 &self.tools,
1779 context_length,
1780 );
1781 let _ = tx
1782 .send(InferenceEvent::PromptPressure {
1783 estimated_input_tokens,
1784 reserved_output_tokens,
1785 estimated_total_tokens,
1786 context_length,
1787 percent,
1788 })
1789 .await;
1790 }
1791
1792 async fn emit_prompt_pressure_idle(&self, tx: &mpsc::Sender<InferenceEvent>) {
1793 let context_length = self.engine.current_context_length();
1794 let _ = tx
1795 .send(InferenceEvent::PromptPressure {
1796 estimated_input_tokens: 0,
1797 reserved_output_tokens: 0,
1798 estimated_total_tokens: 0,
1799 context_length,
1800 percent: 0,
1801 })
1802 .await;
1803 }
1804
1805 async fn emit_compaction_pressure(&self, tx: &mpsc::Sender<InferenceEvent>) {
1806 let context_length = self.engine.current_context_length();
1807 let vram_ratio = self.gpu_state.ratio();
1808 let config = CompactionConfig::adaptive(context_length, vram_ratio);
1809 let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
1810 let percent = if config.max_estimated_tokens == 0 {
1811 0
1812 } else {
1813 ((estimated_tokens.saturating_mul(100)) / config.max_estimated_tokens).min(100) as u8
1814 };
1815
1816 let _ = tx
1817 .send(InferenceEvent::CompactionPressure {
1818 estimated_tokens,
1819 threshold_tokens: config.max_estimated_tokens,
1820 percent,
1821 })
1822 .await;
1823 }
1824
1825 async fn refresh_runtime_profile_and_report(
1826 &mut self,
1827 tx: &mpsc::Sender<InferenceEvent>,
1828 reason: &str,
1829 ) -> Option<(String, usize, bool)> {
1830 let refreshed = self.engine.refresh_runtime_profile().await;
1831 if let Some((model_id, context_length, changed)) = refreshed.as_ref() {
1832 let _ = tx
1833 .send(InferenceEvent::RuntimeProfile {
1834 provider_name: self.engine.provider_name().await,
1835 endpoint: crate::runtime::session_endpoint_url(&self.engine.base_url),
1836 model_id: model_id.clone(),
1837 context_length: *context_length,
1838 })
1839 .await;
1840 self.transcript.log_system(&format!(
1841 "Runtime profile refresh ({}): model={} ctx={} changed={}",
1842 reason, model_id, context_length, changed
1843 ));
1844 } else {
1845 let provider_name = self.engine.provider_name().await;
1846 let endpoint = crate::runtime::session_endpoint_url(&self.engine.base_url);
1847 let mut summary = format!("{} profile refresh failed at {}", provider_name, endpoint);
1848 if let Some((alt_name, alt_url)) =
1849 crate::runtime::detect_alternative_provider(&provider_name).await
1850 {
1851 summary.push_str(&format!(
1852 " | reachable alternative: {} ({})",
1853 alt_name, alt_url
1854 ));
1855 }
1856 let _ = tx
1857 .send(InferenceEvent::ProviderStatus {
1858 state: ProviderRuntimeState::Degraded,
1859 summary: summary.clone(),
1860 })
1861 .await;
1862 self.transcript.log_system(&format!(
1863 "Runtime profile refresh ({}) failed: {}",
1864 reason, summary
1865 ));
1866 }
1867 refreshed
1868 }
1869
1870 async fn emit_embed_profile(&self, tx: &mpsc::Sender<InferenceEvent>) {
1871 let embed_model = self.engine.get_embedding_model().await;
1872 self.vein.set_embed_model(embed_model.clone());
1873 let _ = tx
1874 .send(InferenceEvent::EmbedProfile {
1875 model_id: embed_model,
1876 })
1877 .await;
1878 }
1879
1880 async fn runtime_model_status_report(
1881 &self,
1882 config: &crate::agent::config::HematiteConfig,
1883 ) -> String {
1884 let provider = self.engine.provider_name().await;
1885 let coding_model = self.engine.current_model();
1886 let coding_pref = crate::agent::config::preferred_coding_model(config)
1887 .unwrap_or_else(|| "none saved".to_string());
1888 let embed_loaded = self
1889 .engine
1890 .get_embedding_model()
1891 .await
1892 .unwrap_or_else(|| "not loaded".to_string());
1893 let embed_pref = config
1894 .embed_model
1895 .clone()
1896 .unwrap_or_else(|| "none saved".to_string());
1897 format!(
1898 "Provider: {}\nCoding model: {} | CTX {}\nPreferred coding model: {}\nEmbedding model: {}\nPreferred embed model: {}\nProvider controls: {}\n\nUse `{}`, `/model prefer <id>`, or `{}`.",
1899 provider,
1900 coding_model,
1901 self.engine.current_context_length(),
1902 coding_pref,
1903 embed_loaded,
1904 embed_pref,
1905 Self::provider_model_controls_summary(&provider),
1906 Self::model_command_usage(),
1907 Self::embed_command_usage()
1908 )
1909 }
1910
1911 fn model_command_usage() -> &'static str {
1912 "/model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear]"
1913 }
1914
1915 fn embed_command_usage() -> &'static str {
1916 "/embed [status|load <id>|unload [id|current]|prefer <id>|clear]"
1917 }
1918
1919 fn provider_model_controls_summary(provider: &str) -> &'static str {
1920 if provider == "Ollama" {
1921 "Ollama supports coding and embed model load/list/unload from Hematite, and `--ctx` maps to Ollama `num_ctx` for coding models."
1922 } else {
1923 "LM Studio supports coding and embed model load/unload from Hematite, and `--ctx` maps to LM Studio context length."
1924 }
1925 }
1926
1927 async fn format_provider_model_inventory(
1928 &self,
1929 provider: &str,
1930 kind: crate::agent::provider::ProviderModelKind,
1931 loaded_only: bool,
1932 ) -> Result<String, String> {
1933 let models = self.engine.list_provider_models(kind, loaded_only).await?;
1934 let scope_label = if loaded_only { "loaded" } else { "available" };
1935 let role_label = match kind {
1936 crate::agent::provider::ProviderModelKind::Any => "models",
1937 crate::agent::provider::ProviderModelKind::Coding => "coding models",
1938 crate::agent::provider::ProviderModelKind::Embed => "embedding models",
1939 };
1940 if models.is_empty() {
1941 return Ok(format!(
1942 "No {} {} detected on {}.",
1943 scope_label, role_label, provider
1944 ));
1945 }
1946 let lines = models
1947 .iter()
1948 .enumerate()
1949 .map(|(idx, model)| format!("{}. {}", idx + 1, model))
1950 .collect::<Vec<_>>()
1951 .join("\n");
1952 Ok(format!(
1953 "{} {} on {}:\n{}",
1954 if loaded_only { "Loaded" } else { "Available" },
1955 role_label,
1956 provider,
1957 lines
1958 ))
1959 }
1960
1961 fn parse_model_load_args(arg_text: &str) -> Result<(String, Option<usize>), String> {
1962 let mut model_id: Option<String> = None;
1963 let mut context_length: Option<usize> = None;
1964 let mut tokens = arg_text.split_whitespace().peekable();
1965
1966 while let Some(token) = tokens.next() {
1967 match token {
1968 "--ctx" | "--context" | "--context-length" => {
1969 let Some(value) = tokens.next() else {
1970 return Err("Missing value for --ctx.".to_string());
1971 };
1972 let parsed = value
1973 .parse::<usize>()
1974 .map_err(|_| format!("Invalid context length `{}`.", value))?;
1975 context_length = Some(parsed);
1976 }
1977 _ if token.starts_with("--ctx=") => {
1978 let value = token.trim_start_matches("--ctx=");
1979 let parsed = value
1980 .parse::<usize>()
1981 .map_err(|_| format!("Invalid context length `{}`.", value))?;
1982 context_length = Some(parsed);
1983 }
1984 _ if token.starts_with("--context-length=") => {
1985 let value = token.trim_start_matches("--context-length=");
1986 let parsed = value
1987 .parse::<usize>()
1988 .map_err(|_| format!("Invalid context length `{}`.", value))?;
1989 context_length = Some(parsed);
1990 }
1991 _ if token.starts_with("--") => {
1992 return Err(format!("Unknown model-load flag `{}`.", token));
1993 }
1994 _ => {
1995 if model_id.is_some() {
1996 return Err(
1997 "Model ID must be one token; if it contains spaces, use the exact local model key without spaces."
1998 .to_string(),
1999 );
2000 }
2001 model_id = Some(token.to_string());
2002 }
2003 }
2004 }
2005
2006 let model_id = model_id.ok_or_else(|| "Missing model ID.".to_string())?;
2007 Ok((model_id, context_length))
2008 }
2009
2010 fn parse_unload_target(arg_text: &str) -> Result<(Option<String>, bool), String> {
2011 let target = arg_text.trim();
2012 if target.is_empty() || target.eq_ignore_ascii_case("current") {
2013 Ok((None, false))
2014 } else if target.eq_ignore_ascii_case("all") {
2015 Ok((None, true))
2016 } else if target.contains(char::is_whitespace) {
2017 Err("Model ID must be one token; if it contains spaces, use the exact local model key without spaces.".to_string())
2018 } else {
2019 Ok((Some(target.to_string()), false))
2020 }
2021 }
2022
2023 async fn load_runtime_model_now(
2024 &mut self,
2025 tx: &mpsc::Sender<InferenceEvent>,
2026 model_id: &str,
2027 role_label: &str,
2028 context_length: Option<usize>,
2029 ) -> Result<String, String> {
2030 let provider = self.engine.provider_name().await;
2031 if role_label == "embed" {
2032 if context_length.is_some() {
2033 return Err(
2034 "Embedding models do not use `/model ... --ctx` semantics here.".to_string(),
2035 );
2036 }
2037 self.engine.load_embedding_model(model_id).await?;
2038 } else {
2039 self.engine
2040 .load_model_with_context(model_id, context_length)
2041 .await?;
2042 }
2043
2044 let refreshed = if provider == "Ollama" {
2045 let ctx =
2046 context_length.unwrap_or_else(|| self.engine.current_context_length().max(8192));
2047 if role_label == "embed" {
2048 None
2049 } else {
2050 self.engine.set_runtime_profile(model_id, ctx).await;
2051 let _ = tx
2052 .send(InferenceEvent::RuntimeProfile {
2053 provider_name: provider.clone(),
2054 endpoint: crate::runtime::session_endpoint_url(&self.engine.base_url),
2055 model_id: model_id.to_string(),
2056 context_length: ctx,
2057 })
2058 .await;
2059 Some((model_id.to_string(), ctx, true))
2060 }
2061 } else {
2062 self.refresh_runtime_profile_and_report(tx, &format!("{}_load", role_label))
2063 .await
2064 };
2065 self.emit_embed_profile(tx).await;
2066
2067 let loaded_embed = self.engine.get_embedding_model().await;
2068 let status = match role_label {
2069 "embed" => format!(
2070 "Requested embed model load for `{}`. Current embedding model: {}.",
2071 model_id,
2072 loaded_embed.unwrap_or_else(|| "not loaded".to_string())
2073 ),
2074 _ => match refreshed {
2075 Some((current, ctx, _)) => format!(
2076 "Requested coding model load for `{}`. Current coding model: {} | CTX {}{}.",
2077 model_id,
2078 current,
2079 ctx,
2080 context_length
2081 .map(|requested| format!(" | requested ctx {}", requested))
2082 .unwrap_or_default()
2083 ),
2084 None => format!(
2085 "Requested coding model load for `{}`. Hematite could not refresh the runtime profile afterward; run `/runtime-refresh` once LM Studio settles.",
2086 model_id
2087 ),
2088 },
2089 };
2090 Ok(status)
2091 }
2092
2093 async fn unload_runtime_model_now(
2094 &mut self,
2095 tx: &mpsc::Sender<InferenceEvent>,
2096 model_id: Option<&str>,
2097 role_label: &str,
2098 unload_all: bool,
2099 ) -> Result<String, String> {
2100 let resolved_target = if unload_all {
2101 None
2102 } else {
2103 match role_label {
2104 "embed" => match model_id {
2105 Some("current") | None => self.engine.get_embedding_model().await,
2106 Some(explicit) => Some(explicit.to_string()),
2107 },
2108 _ => match model_id {
2109 Some("current") | None => {
2110 let current = self.engine.current_model();
2111 let normalized = current.trim();
2112 if normalized.is_empty()
2113 || normalized.eq_ignore_ascii_case("no model loaded")
2114 {
2115 None
2116 } else {
2117 Some(normalized.to_string())
2118 }
2119 }
2120 Some(explicit) => Some(explicit.to_string()),
2121 },
2122 }
2123 };
2124
2125 if !unload_all && resolved_target.is_none() {
2126 return Err(match role_label {
2127 "embed" => "No embedding model is currently loaded.".to_string(),
2128 _ => "No coding model is currently loaded.".to_string(),
2129 });
2130 }
2131
2132 let outcome = if role_label == "embed" {
2133 self.engine
2134 .unload_embedding_model(resolved_target.as_deref())
2135 .await?
2136 } else {
2137 self.engine
2138 .unload_model(resolved_target.as_deref(), unload_all)
2139 .await?
2140 };
2141 let _ = self
2142 .refresh_runtime_profile_and_report(tx, &format!("{}_unload", role_label))
2143 .await;
2144 self.emit_embed_profile(tx).await;
2145 Ok(outcome)
2146 }
2147
2148 pub fn new(
2149 engine: Arc<InferenceEngine>,
2150 professional: bool,
2151 brief: bool,
2152 snark: u8,
2153 chaos: u8,
2154 soul_personality: String,
2155 fast_model: Option<String>,
2156 think_model: Option<String>,
2157 gpu_state: Arc<GpuState>,
2158 git_state: Arc<crate::agent::git_monitor::GitState>,
2159 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
2160 voice_manager: Arc<crate::ui::voice::VoiceManager>,
2161 ) -> Self {
2162 let saved = load_session_data();
2163
2164 let mcp_manager = Arc::new(tokio::sync::Mutex::new(
2166 crate::agent::mcp_manager::McpManager::new(),
2167 ));
2168
2169 let dynamic_instructions =
2171 engine.build_system_prompt(snark, chaos, brief, professional, &[], None, None, &[]);
2172
2173 let history = vec![ChatMessage::system(&dynamic_instructions)];
2174
2175 let vein_path = crate::tools::file_ops::hematite_dir().join("vein.db");
2176 let vein_base_url = engine.base_url.clone();
2177 let vein = crate::memory::vein::Vein::new(&vein_path, vein_base_url.clone())
2178 .unwrap_or_else(|_| crate::memory::vein::Vein::new(":memory:", vein_base_url).unwrap());
2179
2180 Self {
2181 history,
2182 engine,
2183 tools: get_tools(),
2184 mcp_manager,
2185 professional,
2186 brief,
2187 snark,
2188 chaos,
2189 fast_model,
2190 think_model,
2191 correction_hints: Vec::new(),
2192 running_summary: saved.running_summary,
2193 gpu_state,
2194 vein,
2195 transcript: crate::agent::transcript::TranscriptLogger::new(),
2196 cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2197 git_state,
2198 think_mode: None,
2199 workflow_mode: WorkflowMode::Auto,
2200 session_memory: saved.session_memory,
2201 swarm_coordinator,
2202 voice_manager,
2203 soul_personality,
2204 lsp_manager: Arc::new(Mutex::new(crate::agent::lsp::manager::LspManager::new(
2205 crate::tools::file_ops::workspace_root(),
2206 ))),
2207 reasoning_history: None,
2208 pinned_files: Arc::new(Mutex::new(std::collections::HashMap::new())),
2209 action_grounding: Arc::new(Mutex::new(ActionGroundingState::default())),
2210 plan_execution_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2211 plan_execution_pass_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2212 recovery_context: RecoveryContext::default(),
2213 l1_context: None,
2214 repo_map: None,
2215 turn_count: saved.turn_count,
2216 last_goal: saved.last_goal,
2217 latest_target_dir: None,
2218 pending_teleport_handoff: None,
2219 last_heartbeat: None,
2220 pending_skill_inject: None,
2221 shell_history_block: crate::agent::shell_history::load_shell_history_block(),
2222 pending_fix_context: None,
2223 last_turn_budget: None,
2224 diff_tracker: Arc::new(Mutex::new(
2225 crate::agent::diff_tracker::TurnDiffTracker::new(),
2226 )),
2227 }
2228 }
2229
2230 async fn emit_done_events(&mut self, tx: &tokio::sync::mpsc::Sender<InferenceEvent>) {
2231 if let Some(path) = self.latest_target_dir.take() {
2232 self.persist_pending_teleport_handoff();
2233 let _ = tx.send(InferenceEvent::CopyDiveInCommand(path)).await;
2234 }
2235 let _ = tx.send(InferenceEvent::Done).await;
2236 }
2237
2238 pub fn initialize_vein(&mut self) -> usize {
2241 self.refresh_vein_index()
2242 }
2243
2244 pub fn initialize_repo_map(&mut self) {
2246 if !self.vein_docs_only_mode() {
2247 let root = crate::tools::file_ops::workspace_root();
2248 let hot = self.vein.hot_files_weighted(10);
2249 let gen = crate::memory::repo_map::RepoMapGenerator::new(&root).with_hot_files(&hot);
2250 match tokio::task::block_in_place(|| gen.generate()) {
2251 Ok(map) => self.repo_map = Some(map),
2252 Err(e) => {
2253 self.repo_map = Some(format!("Repo Map generation failed: {}", e));
2254 }
2255 }
2256 }
2257 }
2258
2259 fn refresh_repo_map(&mut self) {
2262 self.initialize_repo_map();
2263 }
2264
2265 fn save_session(&self) {
2266 let path = session_path();
2267 if let Some(parent) = path.parent() {
2268 let _ = std::fs::create_dir_all(parent);
2269 }
2270 let saved = SavedSession {
2271 running_summary: self.running_summary.clone(),
2272 session_memory: self.session_memory.clone(),
2273 last_goal: self.last_goal.clone(),
2274 turn_count: self.turn_count,
2275 };
2276 if let Ok(json) = serde_json::to_string(&saved) {
2277 let _ = std::fs::write(&path, json);
2278 }
2279 }
2280
2281 fn save_empty_session(&self) {
2282 let path = session_path();
2283 if let Some(parent) = path.parent() {
2284 let _ = std::fs::create_dir_all(parent);
2285 }
2286 let saved = SavedSession {
2287 running_summary: None,
2288 session_memory: crate::agent::compaction::SessionMemory::default(),
2289 last_goal: None,
2290 turn_count: 0,
2291 };
2292 if let Ok(json) = serde_json::to_string(&saved) {
2293 let _ = std::fs::write(&path, json);
2294 }
2295 }
2296
2297 fn refresh_session_memory(&mut self) {
2298 let current_plan = self.session_memory.current_plan.clone();
2299 let previous_memory = self.session_memory.clone();
2300 self.session_memory = compaction::extract_memory(&self.history);
2301 self.session_memory.current_plan = current_plan;
2302 self.session_memory
2303 .inherit_runtime_ledger_from(&previous_memory);
2304 }
2305
2306 fn build_chat_system_prompt(&self) -> String {
2307 let species = &self.engine.species;
2308 let personality = &self.soul_personality;
2309 let mut sys = format!(
2310 "You are {species}, a local AI companion running entirely on the user's GPU — no cloud, no subscriptions, no phoning home.\n\
2311 {personality}\n\n\
2312 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"
2313 );
2314
2315 if let Some(summary) = self.last_heartbeat.as_ref() {
2316 sys.push_str("## HOST ENVIRONMENT\n");
2317 sys.push_str(&summary.to_summary());
2318 sys.push_str("\n\n");
2319 }
2320
2321 sys.push_str(
2322 "Rules:\n\
2323 - Talk like a person. Skip the bullet-point breakdowns unless the topic genuinely needs structure.\n\
2324 - Answer directly. One paragraph is usually right.\n\
2325 - Don't call tools unless the user explicitly asks you to look at a file or run something.\n\
2326 - Don't narrate your reasoning or mention tool names unprompted.\n\
2327 - You can discuss code, debug ideas, explain concepts, help plan, or just talk.\n\
2328 - If the user clearly wants you to edit or build something, do it — but lead with conversation, not scaffolding.\n\
2329 - If the user wants the full coding harness, they can type `/agent`.\n",
2330 );
2331 sys
2332 }
2333
2334 fn append_session_handoff(&self, system_msg: &mut String) {
2335 let has_summary = self
2336 .running_summary
2337 .as_ref()
2338 .map(|s| !s.trim().is_empty())
2339 .unwrap_or(false);
2340 let has_memory = self.session_memory.has_signal();
2341
2342 if !has_summary && !has_memory {
2343 return;
2344 }
2345
2346 system_msg.push_str(
2347 "\n\n# LIGHTWEIGHT SESSION HANDOFF\n\
2348 This is compact carry-over from earlier work on this machine.\n\
2349 Use it only when it helps the current request.\n\
2350 Prefer current repository state, pinned files, and fresh tool results over stale session memory.\n",
2351 );
2352
2353 if has_memory {
2354 system_msg.push_str("\n## Active Task Memory\n");
2355 system_msg.push_str(&self.session_memory.to_prompt());
2356 }
2357
2358 if let Some(summary) = self.running_summary.as_deref() {
2359 if !summary.trim().is_empty() {
2360 system_msg.push_str("\n## Compacted Session Summary\n");
2361 system_msg.push_str(summary);
2362 system_msg.push('\n');
2363 }
2364 }
2365 }
2366
2367 fn set_workflow_mode(&mut self, mode: WorkflowMode) {
2368 self.workflow_mode = mode;
2369 }
2370
2371 fn current_plan_summary(&self) -> Option<String> {
2372 self.session_memory
2373 .current_plan
2374 .as_ref()
2375 .filter(|plan| plan.has_signal())
2376 .map(|plan| plan.summary_line())
2377 }
2378
2379 fn current_plan_allowed_paths(&self) -> Vec<String> {
2380 self.session_memory
2381 .current_plan
2382 .as_ref()
2383 .map(|plan| merge_plan_allowed_paths(&plan.target_files))
2384 .unwrap_or_default()
2385 }
2386
2387 fn current_plan_root_paths(&self) -> Vec<String> {
2388 use std::collections::BTreeSet;
2389
2390 let mut roots = BTreeSet::new();
2391 for path in self.current_plan_allowed_paths() {
2392 if let Some(parent) = std::path::Path::new(&path).parent() {
2393 roots.insert(parent.to_string_lossy().replace('\\', "/").to_lowercase());
2394 }
2395 }
2396 roots.into_iter().collect()
2397 }
2398
2399 fn persist_architect_handoff(
2400 &mut self,
2401 response: &str,
2402 ) -> Option<crate::tools::plan::PlanHandoff> {
2403 if self.workflow_mode != WorkflowMode::Architect {
2404 return None;
2405 }
2406 let Some(plan) = crate::tools::plan::parse_plan_handoff(response) else {
2407 return None;
2408 };
2409 let _ = crate::tools::plan::save_plan_handoff(&plan);
2410 self.session_memory.current_plan = Some(plan.clone());
2411 Some(plan)
2412 }
2413
2414 fn persist_pending_teleport_handoff(&mut self) {
2415 let Some(handoff) = self.pending_teleport_handoff.take() else {
2416 return;
2417 };
2418 let root = std::path::PathBuf::from(&handoff.root);
2419 let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &handoff.plan);
2420 let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
2421 }
2422
2423 async fn begin_grounded_turn(&self) -> u64 {
2424 let mut state = self.action_grounding.lock().await;
2425 state.turn_index += 1;
2426 state.turn_index
2427 }
2428
2429 async fn reset_action_grounding(&self) {
2430 let mut state = self.action_grounding.lock().await;
2431 *state = ActionGroundingState::default();
2432 }
2433
2434 async fn register_at_file_mentions(&self, input: &str) {
2438 if !input.contains('@') {
2439 return;
2440 }
2441 let cwd = match std::env::current_dir() {
2442 Ok(d) => d,
2443 Err(_) => return,
2444 };
2445 let mut state = self.action_grounding.lock().await;
2446 let turn = state.turn_index;
2447 for token in input.split_whitespace() {
2448 if !token.starts_with('@') {
2449 continue;
2450 }
2451 let raw = token
2452 .trim_start_matches('@')
2453 .trim_end_matches(|c: char| matches!(c, ',' | '.' | ':' | ';' | '!' | '?'));
2454 if raw.is_empty() {
2455 continue;
2456 }
2457 if cwd.join(raw).is_file() {
2458 let normalized = normalize_workspace_path(raw);
2459 state.observed_paths.insert(normalized.clone(), turn);
2460 state.inspected_paths.insert(normalized, turn);
2461 }
2462 }
2463 }
2464
2465 async fn record_read_observation(&self, path: &str) {
2466 let normalized = normalize_workspace_path(path);
2467 let mut state = self.action_grounding.lock().await;
2468 let turn = state.turn_index;
2469 state.observed_paths.insert(normalized.clone(), turn);
2473 state.inspected_paths.insert(normalized, turn);
2474 }
2475
2476 async fn record_line_inspection(&self, path: &str) {
2477 let normalized = normalize_workspace_path(path);
2478 let mut state = self.action_grounding.lock().await;
2479 let turn = state.turn_index;
2480 state.observed_paths.insert(normalized.clone(), turn);
2481 state.inspected_paths.insert(normalized, turn);
2482 }
2483
2484 async fn record_verify_build_result(&self, ok: bool, output: &str) {
2485 let mut state = self.action_grounding.lock().await;
2486 let turn = state.turn_index;
2487 state.last_verify_build_turn = Some(turn);
2488 state.last_verify_build_ok = ok;
2489 if ok {
2490 state.code_changed_since_verify = false;
2491 state.last_failed_build_paths.clear();
2492 } else {
2493 state.last_failed_build_paths = parse_failing_paths_from_build_output(output);
2494 }
2495 }
2496
2497 fn record_session_verification(&mut self, ok: bool, summary: impl Into<String>) {
2498 self.session_memory.record_verification(ok, summary);
2499 }
2500
2501 async fn record_successful_mutation(&self, path: Option<&str>) {
2502 let mut state = self.action_grounding.lock().await;
2503 state.code_changed_since_verify = match path {
2504 Some(p) => is_code_like_path(p),
2505 None => true,
2506 };
2507 }
2508
2509 async fn validate_action_preconditions(&self, name: &str, args: &Value) -> Result<(), String> {
2510 if let Some(steer_hint) =
2512 crate::agent::policy::is_redundant_action(name, args, &self.history)
2513 {
2514 return Err(steer_hint);
2515 }
2516
2517 if name == "shell" {
2518 if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
2519 if !crate::agent::policy::find_binary_in_path(cmd) {
2520 return Err(format!(
2521 "PREDICTIVE FAILURE: The binary for the command `{}` was not found in the host PATH. \
2522 Do not attempt to run this command. Either troubleshoot the toolchain \
2523 using `inspect_host(topic='fix_plan')` or ask the user to verify its installation.",
2524 cmd
2525 ));
2526 }
2527 }
2528 }
2529
2530 if self
2531 .plan_execution_active
2532 .load(std::sync::atomic::Ordering::SeqCst)
2533 {
2534 if is_current_plan_irrelevant_tool(name) {
2535 let prompt = self.latest_user_prompt().unwrap_or("");
2536 let plan_override = self
2537 .session_memory
2538 .current_plan
2539 .as_ref()
2540 .map(|plan| plan_handoff_mentions_tool(plan, name))
2541 .unwrap_or(false);
2542 let explicit_override = is_sovereign_path_request(prompt)
2543 || prompt.contains(name)
2544 || prompt.contains("/dev/null")
2545 || plan_override;
2546 if !explicit_override {
2547 return Err(format!(
2548 "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.",
2549 name
2550 ));
2551 }
2552 }
2553
2554 if is_plan_scoped_tool(name) {
2555 let allowed_paths = self.current_plan_allowed_paths();
2556 if !allowed_paths.is_empty() {
2557 let allowed_roots = self.current_plan_root_paths();
2558 let in_allowed = match name {
2559 "auto_pin_context" => args
2560 .get("paths")
2561 .and_then(|v| v.as_array())
2562 .map(|paths| {
2563 !paths.is_empty()
2564 && paths.iter().all(|v| {
2565 v.as_str()
2566 .map(normalize_workspace_path)
2567 .map(|p| allowed_paths.contains(&p))
2568 .unwrap_or(false)
2569 })
2570 })
2571 .unwrap_or(false),
2572 "grep_files" | "list_files" => {
2573 let raw_val = args.get("path").and_then(|v| v.as_str());
2574 let path_to_check = if let Some(p) = raw_val {
2575 let trimmed = p.trim();
2576 if trimmed.is_empty() || trimmed == "." || trimmed == "./" {
2577 ""
2578 } else {
2579 trimmed
2580 }
2581 } else {
2582 ""
2583 };
2584 if path_to_check.is_empty() {
2587 true
2588 } else {
2589 let p = normalize_workspace_path(path_to_check);
2590 allowed_paths.contains(&p)
2593 || allowed_roots.iter().any(|root| root == &p)
2594 || allowed_paths.iter().any(|ap| {
2595 ap.starts_with(&format!("{}/", p))
2596 || ap.starts_with(&format!("{}\\", p))
2597 })
2598 }
2599 }
2600 _ => {
2601 let target = action_target_path(name, args);
2602 let in_allowed = target
2603 .as_ref()
2604 .map(|p| allowed_paths.contains(p))
2605 .unwrap_or(false);
2606 let raw_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
2607 in_allowed || is_sovereign_path_request(raw_path)
2608 }
2609 };
2610
2611 if !in_allowed {
2612 let allowed = allowed_paths
2613 .iter()
2614 .map(|p| format!("`{}`", p))
2615 .collect::<Vec<_>>()
2616 .join(", ");
2617 return Err(format!(
2618 "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: {}.",
2619 allowed
2620 ));
2621 }
2622 }
2623 }
2624
2625 if matches!(name, "edit_file" | "multi_search_replace" | "patch_hunk") {
2626 if let Some(target) = action_target_path(name, args) {
2627 let state = self.action_grounding.lock().await;
2628 let recently_inspected = state
2629 .inspected_paths
2630 .get(&target)
2631 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2632 .unwrap_or(false);
2633 drop(state);
2634 if !recently_inspected {
2635 return Err(format!(
2636 "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.",
2637 name, target
2638 ));
2639 }
2640 }
2641 }
2642 }
2643
2644 if self.workflow_mode.is_read_only() && name == "auto_pin_context" {
2645 return Err(
2646 "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."
2647 .to_string(),
2648 );
2649 }
2650
2651 if self.workflow_mode.is_read_only() && is_destructive_tool(name) {
2652 if name == "shell" {
2653 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2654 let risk = crate::tools::guard::classify_bash_risk(command);
2655 if !matches!(risk, crate::tools::RiskLevel::Safe) {
2656 return Err(format!(
2657 "Action blocked: workflow mode `{}` is read-only for risky or mutating operations. Switch to `/code` or `/auto` before making changes.",
2658 self.workflow_mode.label()
2659 ));
2660 }
2661 } else {
2662 return Err(format!(
2663 "Action blocked: workflow mode `{}` is read-only. Use `/code` to implement changes or `/auto` to leave mode selection to Hematite.",
2664 self.workflow_mode.label()
2665 ));
2666 }
2667 }
2668
2669 let normalized_target = action_target_path(name, args);
2670 if let Some(target) = normalized_target.as_deref() {
2671 if matches!(
2672 name,
2673 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2674 ) {
2675 if let Some(prompt) = self.latest_user_prompt() {
2676 if docs_edit_without_explicit_request(prompt, target) {
2677 return Err(format!(
2678 "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.",
2679 target
2680 ));
2681 }
2682 }
2683 }
2684 let path_exists = std::path::Path::new(target).exists();
2685 if path_exists {
2686 let state = self.action_grounding.lock().await;
2687 let pinned = self.pinned_files.lock().await;
2688 let pinned_match = pinned.keys().any(|p| normalize_workspace_path(p) == target);
2689 drop(pinned);
2690
2691 let needs_exact_window = matches!(name, "edit_file" | "multi_search_replace");
2696 let recently_inspected = state
2697 .inspected_paths
2698 .get(target)
2699 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2700 .unwrap_or(false);
2701 let same_turn_read = state
2702 .observed_paths
2703 .get(target)
2704 .map(|turn| state.turn_index.saturating_sub(*turn) == 0)
2705 .unwrap_or(false);
2706 let recent_observed = state
2707 .observed_paths
2708 .get(target)
2709 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
2710 .unwrap_or(false);
2711
2712 if matches!(
2713 name,
2714 "read_file" | "inspect_lines" | "list_files" | "grep_files"
2715 ) {
2716 } else if name == "write_file" && matches!(self.workflow_mode, WorkflowMode::Code) {
2719 let size = std::fs::metadata(target).map(|m| m.len()).unwrap_or(0);
2720 if size > 2000 {
2721 return Err(format!(
2723 "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`.",
2724 target, size
2725 ));
2726 }
2727 } else if needs_exact_window {
2728 if !recently_inspected && !same_turn_read && !pinned_match {
2729 return Err(format!(
2730 "Action blocked: `{}` on '{}' requires a line-level inspection first. \
2731 Use `inspect_lines` on the target region to get the exact current text \
2732 (whitespace and indentation included), then retry the edit.",
2733 name, target
2734 ));
2735 }
2736 } else if !recent_observed && !pinned_match {
2737 return Err(format!(
2738 "Action blocked: `{}` on '{}' requires recent file evidence. Use `read_file` or `inspect_lines` on that path first, or pin the file into active context.",
2739 name, target
2740 ));
2741 }
2742 }
2743 }
2744
2745 if is_mcp_mutating_tool(name) {
2746 return Err(format!(
2747 "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.",
2748 name
2749 ));
2750 }
2751
2752 if is_mcp_workspace_read_tool(name) {
2753 return Err(format!(
2754 "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.",
2755 name
2756 ));
2757 }
2758
2759 if matches!(
2762 name,
2763 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
2764 ) {
2765 if let Some(target) = normalized_target.as_deref() {
2766 let state = self.action_grounding.lock().await;
2767 if state.code_changed_since_verify
2768 && !state.last_verify_build_ok
2769 && !state.last_failed_build_paths.is_empty()
2770 && !state.last_failed_build_paths.iter().any(|p| p == target)
2771 {
2772 let files = state
2773 .last_failed_build_paths
2774 .iter()
2775 .map(|p| format!("`{}`", p))
2776 .collect::<Vec<_>>()
2777 .join(", ");
2778 return Err(format!(
2779 "Action blocked: the build is broken. Fix the errors in {} before editing other files. Re-run workspace verification to confirm the fix, then continue.",
2780 files
2781 ));
2782 }
2783 }
2784 }
2785
2786 if name == "git_commit" || name == "git_push" {
2787 let state = self.action_grounding.lock().await;
2788 if state.code_changed_since_verify && !state.last_verify_build_ok {
2789 return Err(format!(
2790 "Action blocked: `{}` requires a successful verification pass after the latest code edits. Run verification first so Hematite has proof that the workspace is clean.",
2791 name
2792 ));
2793 }
2794 }
2795
2796 if name == "shell" {
2797 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2798 if shell_looks_like_structured_host_inspection(command) {
2799 let topic = match preferred_host_inspection_topic(command) {
2804 Some(t) => t.to_string(),
2805 None => return Ok(()), };
2807
2808 {
2809 let mut state = self.action_grounding.lock().await;
2810 let current_turn = state.turn_index;
2811 if let Some(turn) = state.redirected_host_inspection_topics.get(&topic) {
2812 if *turn == current_turn {
2813 return Err(format!(
2814 "[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."
2815 ));
2816 }
2817 }
2818 state
2819 .redirected_host_inspection_topics
2820 .insert(topic.clone(), current_turn);
2821 }
2822
2823 let path_val = self
2824 .latest_user_prompt()
2825 .and_then(|p| {
2826 p.split_whitespace()
2828 .find(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
2829 .map(|s| {
2830 s.trim_matches(|c: char| {
2831 !c.is_alphanumeric() && c != '.' && c != '/' && c != '\\'
2832 })
2833 })
2834 })
2835 .unwrap_or("");
2836
2837 let mut redirect_args = if !path_val.is_empty() {
2838 serde_json::json!({ "topic": topic, "path": path_val })
2839 } else {
2840 serde_json::json!({ "topic": topic })
2841 };
2842
2843 if topic == "dns_lookup" {
2845 if let Some(identity) = extract_dns_lookup_target_from_shell(command) {
2846 redirect_args
2847 .as_object_mut()
2848 .unwrap()
2849 .insert("name".to_string(), serde_json::Value::String(identity));
2850 }
2851 if let Some(record_type) = extract_dns_record_type_from_shell(command) {
2852 redirect_args.as_object_mut().unwrap().insert(
2853 "type".to_string(),
2854 serde_json::Value::String(record_type.to_string()),
2855 );
2856 }
2857 } else if topic == "ad_user" {
2858 let cmd_lower = command.to_lowercase();
2859 let mut identity = String::new();
2860
2861 if let Some(idx) = cmd_lower.find("-identity") {
2863 let after_id = &command[idx + 9..].trim();
2864 identity = if after_id.starts_with('\'') || after_id.starts_with('"') {
2865 let quote = after_id.chars().next().unwrap();
2866 after_id.split(quote).nth(1).unwrap_or("").to_string()
2867 } else {
2868 after_id.split_whitespace().next().unwrap_or("").to_string()
2869 };
2870 }
2871
2872 if identity.is_empty() {
2874 let parts: Vec<&str> = command.split_whitespace().collect();
2875 for (i, part) in parts.iter().enumerate() {
2876 if i == 0 || part.starts_with('-') {
2877 continue;
2878 }
2879 let p_low = part.to_lowercase();
2881 if p_low.contains("get-ad")
2882 || p_low.contains("powershell")
2883 || p_low == "-command"
2884 {
2885 continue;
2886 }
2887
2888 identity = part
2889 .trim_matches(|c: char| c == '\'' || c == '"')
2890 .to_string();
2891 if !identity.is_empty() {
2892 break;
2893 }
2894 }
2895 }
2896
2897 if !identity.is_empty() {
2898 redirect_args.as_object_mut().unwrap().insert(
2899 "name_filter".to_string(),
2900 serde_json::Value::String(identity),
2901 );
2902 }
2903 }
2904
2905 let result = crate::tools::host_inspect::inspect_host(&redirect_args).await;
2906 return match result {
2907 Ok(output) => Err(format!(
2908 "[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.]"
2909 )),
2910 Err(e) => Err(format!(
2911 "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.",
2912 )),
2913 };
2914 }
2915 let reason = args
2916 .get("reason")
2917 .and_then(|v| v.as_str())
2918 .unwrap_or("")
2919 .trim();
2920 let risk = crate::tools::guard::classify_bash_risk(command);
2921 if !matches!(risk, crate::tools::RiskLevel::Safe) && reason.is_empty() {
2922 return Err(
2923 "Action blocked: risky `shell` calls require a concrete `reason` argument that explains what is being verified or changed."
2924 .to_string(),
2925 );
2926 }
2927 }
2928
2929 Ok(())
2930 }
2931
2932 fn build_action_receipt(
2933 &self,
2934 name: &str,
2935 args: &Value,
2936 output: &str,
2937 is_error: bool,
2938 ) -> Option<ChatMessage> {
2939 if is_error || !is_destructive_tool(name) {
2940 return None;
2941 }
2942
2943 let mut receipt = String::from("[ACTION RECEIPT]\n");
2944 receipt.push_str(&format!("- tool: {}\n", name));
2945 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
2946 receipt.push_str(&format!("- target: {}\n", path));
2947 }
2948 if name == "shell" {
2949 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
2950 receipt.push_str(&format!("- command: {}\n", command));
2951 }
2952 if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
2953 if !reason.trim().is_empty() {
2954 receipt.push_str(&format!("- reason: {}\n", reason.trim()));
2955 }
2956 }
2957 }
2958 let first_line = output.lines().next().unwrap_or(output).trim();
2959 receipt.push_str(&format!("- outcome: {}\n", first_line));
2960 Some(ChatMessage::system(&receipt))
2961 }
2962
2963 fn replace_mcp_tool_definitions(&mut self, mcp_tools: &[crate::agent::mcp::McpTool]) {
2964 self.tools
2965 .retain(|tool| !tool.function.name.starts_with("mcp__"));
2966 self.tools
2967 .extend(mcp_tools.iter().map(|tool| ToolDefinition {
2968 tool_type: "function".into(),
2969 function: ToolFunction {
2970 name: tool.name.clone(),
2971 description: tool.description.clone().unwrap_or_default(),
2972 parameters: tool.input_schema.clone(),
2973 },
2974 metadata: crate::agent::inference::tool_metadata_for_name(&tool.name),
2975 }));
2976 }
2977
2978 async fn emit_mcp_runtime_status(&self, tx: &mpsc::Sender<InferenceEvent>) {
2979 let summary = {
2980 let mcp = self.mcp_manager.lock().await;
2981 mcp.runtime_report()
2982 };
2983 let _ = tx
2984 .send(InferenceEvent::McpStatus {
2985 state: summary.state,
2986 summary: summary.summary,
2987 })
2988 .await;
2989 }
2990
2991 async fn refresh_mcp_tools(
2992 &mut self,
2993 tx: &mpsc::Sender<InferenceEvent>,
2994 ) -> Result<Vec<crate::agent::mcp::McpTool>, Box<dyn std::error::Error + Send + Sync>> {
2995 let mcp_tools = {
2996 let mut mcp = self.mcp_manager.lock().await;
2997 match mcp.initialize_all().await {
2998 Ok(()) => mcp.discover_tools().await,
2999 Err(e) => {
3000 drop(mcp);
3001 self.replace_mcp_tool_definitions(&[]);
3002 self.emit_mcp_runtime_status(tx).await;
3003 return Err(e.into());
3004 }
3005 }
3006 };
3007
3008 self.replace_mcp_tool_definitions(&mcp_tools);
3009 self.emit_mcp_runtime_status(tx).await;
3010 Ok(mcp_tools)
3011 }
3012
3013 pub async fn initialize_mcp(
3015 &mut self,
3016 tx: &mpsc::Sender<InferenceEvent>,
3017 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
3018 let _ = self.refresh_mcp_tools(tx).await?;
3019 Ok(())
3020 }
3021
3022 pub async fn run_turn(
3028 &mut self,
3029 user_turn: &UserTurn,
3030 tx: mpsc::Sender<InferenceEvent>,
3031 yolo: bool,
3032 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
3033 let user_input = user_turn.text.as_str();
3034 if user_input.trim() == "/new" {
3036 self.history.clear();
3037 self.reasoning_history = None;
3038 self.session_memory.clear();
3039 self.running_summary = None;
3040 self.correction_hints.clear();
3041 self.pinned_files.lock().await.clear();
3042 self.reset_action_grounding().await;
3043 reset_task_files();
3044 let _ = std::fs::remove_file(session_path());
3045 self.save_empty_session();
3046 self.emit_compaction_pressure(&tx).await;
3047 self.emit_prompt_pressure_idle(&tx).await;
3048 for chunk in chunk_text(
3049 "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
3050 8,
3051 ) {
3052 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3053 }
3054 let _ = tx.send(InferenceEvent::Done).await;
3055 return Ok(());
3056 }
3057
3058 if user_input.trim() == "/forget" {
3059 self.history.clear();
3060 self.reasoning_history = None;
3061 self.session_memory.clear();
3062 self.running_summary = None;
3063 self.correction_hints.clear();
3064 self.pinned_files.lock().await.clear();
3065 self.reset_action_grounding().await;
3066 reset_task_files();
3067 crate::agent::tasks::clear();
3068 purge_persistent_memory();
3069 tokio::task::block_in_place(|| self.vein.reset());
3070 let _ = std::fs::remove_file(session_path());
3071 self.save_empty_session();
3072 self.emit_compaction_pressure(&tx).await;
3073 self.emit_prompt_pressure_idle(&tx).await;
3074 for chunk in chunk_text(
3075 "Hard forget complete. Chat history, saved memory, task files, task list, and the Vein index were purged.",
3076 8,
3077 ) {
3078 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3079 }
3080 let _ = tx.send(InferenceEvent::Done).await;
3081 return Ok(());
3082 }
3083
3084 if user_input.trim() == "/vein-inspect" {
3085 let indexed = self.refresh_vein_index();
3086 let report = self.build_vein_inspection_report(indexed);
3087 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
3088 let _ = tx
3089 .send(InferenceEvent::VeinStatus {
3090 file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
3091 embedded_count: snapshot.embedded_source_doc_chunks,
3092 docs_only: self.vein_docs_only_mode(),
3093 })
3094 .await;
3095 for chunk in chunk_text(&report, 8) {
3096 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3097 }
3098 let _ = tx.send(InferenceEvent::Done).await;
3099 return Ok(());
3100 }
3101
3102 if user_input.trim() == "/workspace-profile" {
3103 let root = crate::tools::file_ops::workspace_root();
3104 let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
3105 let report = crate::agent::workspace_profile::profile_report(&root);
3106 for chunk in chunk_text(&report, 8) {
3107 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3108 }
3109 let _ = tx.send(InferenceEvent::Done).await;
3110 return Ok(());
3111 }
3112
3113 if user_input.trim() == "/rules" {
3114 let workspace_root = crate::tools::file_ops::workspace_root();
3115 let report = {
3116 let mut combined = String::new();
3117 for name in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
3118 let path =
3119 crate::agent::instructions::resolve_guidance_path(&workspace_root, name);
3120 if !path.exists() {
3121 continue;
3122 }
3123 match std::fs::read_to_string(&path) {
3124 Ok(content) => {
3125 combined.push_str(&format!("## {}\n\n{}\n\n", name, content.trim()));
3126 }
3127 Err(e) => {
3128 combined.push_str(&format!(
3129 "## {}\n\nError reading {}: {}\n\n",
3130 name,
3131 path.display(),
3132 e
3133 ));
3134 }
3135 }
3136 }
3137 if combined.is_empty() {
3138 "No project guidance files found.\n\nRecognized files: `CLAUDE.md`, `SKILLS.md`, `SKILL.md`, `HEMATITE.md`, `.hematite/rules.md`, `.hematite/rules.local.md`, and `.hematite/instructions.md`.\n\nCreate one of those files to inject workspace-specific guidance on the next turn.".to_string()
3139 } else {
3140 format!(
3141 "## Project Guidance\n\n{}---\nTo update shared rules, open `.hematite/rules.md`. To add workspace-specific recipes or conventions, use `SKILLS.md` or `SKILL.md` in the workspace root. Changes take effect on the next turn.",
3142 combined
3143 )
3144 }
3145 };
3146 for chunk in chunk_text(&report, 8) {
3147 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3148 }
3149 let _ = tx.send(InferenceEvent::Done).await;
3150 return Ok(());
3151 }
3152
3153 if user_input.trim() == "/skills" {
3154 let workspace_root = crate::tools::file_ops::workspace_root();
3155 let config = crate::agent::config::load_config();
3156 let discovery =
3157 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3158 let report = crate::agent::instructions::render_skills_report(&discovery);
3159 for chunk in chunk_text(&report, 8) {
3160 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3161 }
3162 let _ = tx.send(InferenceEvent::Done).await;
3163 return Ok(());
3164 }
3165
3166 if let Some(skill_name) = user_input
3168 .trim()
3169 .strip_prefix("/skill ")
3170 .map(str::trim)
3171 .filter(|s| !s.is_empty())
3172 {
3173 let workspace_root = crate::tools::file_ops::workspace_root();
3174 let config = crate::agent::config::load_config();
3175 let discovery =
3176 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3177 let name_lower = skill_name.to_lowercase();
3178 if let Some(skill) = discovery
3179 .skills
3180 .iter()
3181 .find(|s| s.name.to_lowercase() == name_lower)
3182 {
3183 if skill.body.is_empty() {
3184 let msg = format!(
3185 "Skill `{}` found but its SKILL.md has no body — add instructions after the frontmatter.",
3186 skill.name
3187 );
3188 for chunk in chunk_text(&msg, 8) {
3189 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3190 }
3191 } else {
3192 self.pending_skill_inject =
3193 Some(format!("## Skill: {}\n{}", skill.name, skill.body));
3194 let msg = format!(
3195 "Skill `{}` loaded — instructions will be active for the next turn.",
3196 skill.name
3197 );
3198 for chunk in chunk_text(&msg, 8) {
3199 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3200 }
3201 }
3202 } else {
3203 let available: Vec<&str> =
3204 discovery.skills.iter().map(|s| s.name.as_str()).collect();
3205 let msg = if available.is_empty() {
3206 format!(
3207 "No skill named `{}` found. No skills are currently discovered.",
3208 skill_name
3209 )
3210 } else {
3211 format!(
3212 "No skill named `{}` found. Available: {}",
3213 skill_name,
3214 available.join(", ")
3215 )
3216 };
3217 for chunk in chunk_text(&msg, 8) {
3218 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3219 }
3220 }
3221 let _ = tx.send(InferenceEvent::Done).await;
3222 return Ok(());
3223 }
3224
3225 if let Some(new_name) = user_input
3227 .trim()
3228 .strip_prefix("/skill new ")
3229 .map(str::trim)
3230 .filter(|s| !s.is_empty())
3231 {
3232 let slug = new_name
3233 .to_lowercase()
3234 .replace(' ', "-")
3235 .chars()
3236 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
3237 .collect::<String>();
3238 let skill_dir = crate::tools::file_ops::workspace_root()
3239 .join(".agents")
3240 .join("skills")
3241 .join(&slug);
3242 let skill_path = skill_dir.join("SKILL.md");
3243 let msg = if skill_path.exists() {
3244 format!(
3245 "Skill `{}` already exists at `{}`.",
3246 slug,
3247 skill_path.display()
3248 )
3249 } else {
3250 match std::fs::create_dir_all(&skill_dir) {
3251 Err(e) => format!("Failed to create skill directory: {}", e),
3252 Ok(()) => {
3253 let template = format!(
3254 "---\nname: {slug}\ndescription: Describe when this skill should activate.\ntriggers: \"\"\n---\n\n## When to use\n\nDescribe the problem or context this skill addresses.\n\n## Instructions\n\n1. Step one.\n2. Step two.\n3. Step three.\n\n## Notes\n\n- Any caveats or edge cases.\n"
3255 );
3256 match std::fs::write(&skill_path, template) {
3257 Ok(()) => format!(
3258 "Created `{}` — edit the description, triggers, and instructions, then use `/skill {}` to load it.",
3259 skill_path.display(),
3260 slug
3261 ),
3262 Err(e) => format!("Failed to write SKILL.md: {}", e),
3263 }
3264 }
3265 }
3266 };
3267 for chunk in chunk_text(&msg, 8) {
3268 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3269 }
3270 let _ = tx.send(InferenceEvent::Done).await;
3271 return Ok(());
3272 }
3273
3274 if user_input.trim() == "/vein-reset" {
3275 tokio::task::block_in_place(|| self.vein.reset());
3276 let _ = tx
3277 .send(InferenceEvent::VeinStatus {
3278 file_count: 0,
3279 embedded_count: 0,
3280 docs_only: self.vein_docs_only_mode(),
3281 })
3282 .await;
3283 for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
3284 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3285 }
3286 let _ = tx.send(InferenceEvent::Done).await;
3287 return Ok(());
3288 }
3289
3290 if user_input.trim() == "/compact" {
3291 let context_length = self.engine.current_context_length();
3292 let vram_ratio = self.gpu_state.ratio();
3293 let config = compaction::CompactionConfig::adaptive(context_length, vram_ratio);
3294 let before_len = self.history.len();
3295 let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
3296 let result = compaction::compact_history(
3297 &self.history,
3298 self.running_summary.as_deref(),
3299 config,
3300 None,
3301 );
3302 let removed = before_len.saturating_sub(result.messages.len());
3303 self.history = result.messages;
3304 self.running_summary = result.summary;
3305 let previous_memory = self.session_memory.clone();
3306 self.session_memory = compaction::extract_memory(&self.history);
3307 self.session_memory
3308 .inherit_runtime_ledger_from(&previous_memory);
3309 self.session_memory.record_compaction(
3310 removed,
3311 format!(
3312 "Manual /compact: task '{}', {} file(s) in working set.",
3313 self.session_memory.current_task,
3314 self.session_memory.working_set.len()
3315 ),
3316 );
3317 self.emit_compaction_pressure(&tx).await;
3318 let after_tokens = compaction::estimate_compactable_tokens(&self.history);
3319 let msg = format!(
3320 "History compacted. {} message(s) summarized, ~{} tokens freed. \
3321 Remaining: ~{} tokens. Active task: \"{}\".",
3322 removed,
3323 estimated_tokens.saturating_sub(after_tokens),
3324 after_tokens,
3325 self.session_memory.current_task,
3326 );
3327 for chunk in chunk_text(&msg, 8) {
3328 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3329 }
3330 let _ = tx.send(InferenceEvent::Done).await;
3331 return Ok(());
3332 }
3333
3334 if user_input.trim() == "/budget" {
3335 let msg = match &self.last_turn_budget {
3336 Some(b) => b.render(),
3337 None => "No turn budget recorded yet — run a prompt first.".to_string(),
3338 };
3339 for chunk in chunk_text(&msg, 8) {
3340 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3341 }
3342 let _ = tx.send(InferenceEvent::Done).await;
3343 return Ok(());
3344 }
3345
3346 {
3348 let trimmed = user_input.trim();
3349
3350 if trimmed == "/task" || trimmed == "/task list" {
3352 let tasks = crate::agent::tasks::load();
3353 let report = crate::agent::tasks::render_list(&tasks);
3354 for chunk in chunk_text(&report, 8) {
3355 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3356 }
3357 let _ = tx.send(InferenceEvent::Done).await;
3358 return Ok(());
3359 }
3360
3361 if let Some(text) = trimmed
3363 .strip_prefix("/task add ")
3364 .map(str::trim)
3365 .filter(|s| !s.is_empty())
3366 {
3367 let tasks = crate::agent::tasks::add(text);
3368 let added = tasks
3369 .iter()
3370 .find(|t| t.text == text.trim())
3371 .map(|t| t.id)
3372 .unwrap_or(0);
3373 let msg = format!("Task {} added: {}", added, text.trim());
3374 for chunk in chunk_text(&msg, 8) {
3375 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3376 }
3377 let _ = tx.send(InferenceEvent::Done).await;
3378 return Ok(());
3379 }
3380
3381 if let Some(n_str) = trimmed.strip_prefix("/task done ").map(str::trim) {
3383 let msg = match n_str.parse::<usize>() {
3384 Ok(n) => match crate::agent::tasks::mark_done(n) {
3385 Ok(tasks) => {
3386 let task = tasks.iter().find(|t| t.id == n);
3387 format!(
3388 "Task {} marked done: {}",
3389 n,
3390 task.map(|t| t.text.as_str()).unwrap_or("")
3391 )
3392 }
3393 Err(e) => e,
3394 },
3395 Err(_) => format!("Usage: /task done <number> (e.g. `/task done 2`)"),
3396 };
3397 for chunk in chunk_text(&msg, 8) {
3398 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3399 }
3400 let _ = tx.send(InferenceEvent::Done).await;
3401 return Ok(());
3402 }
3403
3404 if let Some(n_str) = trimmed.strip_prefix("/task remove ").map(str::trim) {
3406 let msg = match n_str.parse::<usize>() {
3407 Ok(n) => match crate::agent::tasks::remove(n) {
3408 Ok(_) => format!("Task {} removed.", n),
3409 Err(e) => e,
3410 },
3411 Err(_) => format!("Usage: /task remove <number> (e.g. `/task remove 3`)"),
3412 };
3413 for chunk in chunk_text(&msg, 8) {
3414 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3415 }
3416 let _ = tx.send(InferenceEvent::Done).await;
3417 return Ok(());
3418 }
3419
3420 if trimmed == "/task clear" {
3422 crate::agent::tasks::clear();
3423 for chunk in chunk_text("All tasks cleared.", 8) {
3424 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3425 }
3426 let _ = tx.send(InferenceEvent::Done).await;
3427 return Ok(());
3428 }
3429 }
3430
3431 {
3433 let trimmed = user_input.trim();
3434
3435 if trimmed == "/pr" || trimmed.starts_with("/pr ") {
3437 let rest = trimmed.strip_prefix("/pr").unwrap_or("").trim();
3438 let draft = rest.contains("--draft");
3439 let title_part = rest.trim_start_matches("--draft").trim();
3440 let title = if title_part.is_empty() {
3441 None
3442 } else {
3443 Some(title_part)
3444 };
3445 let msg = match crate::tools::github::create_pr_from_context(title, draft) {
3446 Ok(out) => out,
3447 Err(e) => format!("PR creation failed: {}", e),
3448 };
3449 for chunk in chunk_text(&msg, 8) {
3450 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3451 }
3452 let _ = tx.send(InferenceEvent::Done).await;
3453 return Ok(());
3454 }
3455
3456 if trimmed == "/ci" {
3458 let msg = match crate::tools::github::ci_status_current() {
3459 Ok(out) if out.trim().is_empty() => {
3460 "No CI runs found for this branch. Push to GitHub and trigger a workflow first.".to_string()
3461 }
3462 Ok(out) => format!("## CI Status\n\n```\n{}\n```", out.trim()),
3463 Err(e) => format!("CI status failed: {}", e),
3464 };
3465 for chunk in chunk_text(&msg, 8) {
3466 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3467 }
3468 let _ = tx.send(InferenceEvent::Done).await;
3469 return Ok(());
3470 }
3471
3472 if trimmed == "/issue" || trimmed.starts_with("/issue ") {
3474 let rest = trimmed.strip_prefix("/issue").unwrap_or("").trim();
3475 let args = if rest.is_empty() {
3476 serde_json::json!({ "action": "issue_list", "limit": 10 })
3477 } else if let Ok(n) = rest.parse::<u64>() {
3478 serde_json::json!({ "action": "issue_view", "number": n })
3479 } else {
3480 serde_json::json!({ "action": "issue_list", "limit": 10, "state": rest })
3481 };
3482 let msg = match crate::tools::github::execute(&args).await {
3483 Ok(out) if out.trim().is_empty() => "No issues found.".to_string(),
3484 Ok(out) => format!("## Issues\n\n```\n{}\n```", out.trim()),
3485 Err(e) => format!("Issue lookup failed: {}", e),
3486 };
3487 for chunk in chunk_text(&msg, 8) {
3488 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3489 }
3490 let _ = tx.send(InferenceEvent::Done).await;
3491 return Ok(());
3492 }
3493 }
3494
3495 if user_input.trim() == "/fix" || user_input.trim() == "/fix --test" {
3497 let action = if user_input.trim() == "/fix --test" {
3498 "test"
3499 } else {
3500 "build"
3501 };
3502 let _ = tx
3503 .send(InferenceEvent::Thought(format!(
3504 "Running verify_build({action}) to capture current error state..."
3505 )))
3506 .await;
3507 let result =
3508 crate::tools::verify_build::execute(&serde_json::json!({ "action": action })).await;
3509 let (ok, output) = match result {
3510 Ok(out) => (true, out),
3511 Err(e) => (false, e),
3512 };
3513 if ok {
3514 for chunk in chunk_text(
3515 &format!(
3516 "Build is clean — nothing to fix.\n\n```\n{}\n```",
3517 output.trim()
3518 ),
3519 8,
3520 ) {
3521 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3522 }
3523 } else {
3524 let capped: String = output.chars().take(3000).collect();
3526 for chunk in chunk_text(
3527 &format!(
3528 "Build failed. Fix context loaded — send any message to start fixing.\n\n```\n{}\n```",
3529 capped.trim()
3530 ),
3531 8,
3532 ) {
3533 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3534 }
3535 self.pending_fix_context = Some(capped);
3536 }
3537 let _ = tx.send(InferenceEvent::Done).await;
3538 return Ok(());
3539 }
3540
3541 let config = crate::agent::config::load_config();
3543 self.recovery_context.clear();
3544 let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
3545 if !manual_runtime_refresh {
3546 if let Some((model_id, context_length, changed)) = self
3547 .refresh_runtime_profile_and_report(&tx, "turn_start")
3548 .await
3549 {
3550 if changed {
3551 let _ = tx
3552 .send(InferenceEvent::Thought(format!(
3553 "Runtime refresh: using model `{}` with CTX {} for this turn.",
3554 model_id, context_length
3555 )))
3556 .await;
3557 }
3558 }
3559 }
3560 self.emit_embed_profile(&tx).await;
3561 self.emit_compaction_pressure(&tx).await;
3562 let current_model = self.engine.current_model();
3563 self.engine.set_gemma_native_formatting(
3564 crate::agent::config::effective_gemma_native_formatting(&config, ¤t_model),
3565 );
3566 let _turn_id = self.begin_grounded_turn().await;
3567 let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
3568 let mcp_tools = match self.refresh_mcp_tools(&tx).await {
3569 Ok(tools) => tools,
3570 Err(e) => {
3571 let _ = tx
3572 .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
3573 .await;
3574 Vec::new()
3575 }
3576 };
3577
3578 let effective_fast = config
3580 .fast_model
3581 .clone()
3582 .or_else(|| self.fast_model.clone());
3583 let effective_think = config
3584 .think_model
3585 .clone()
3586 .or_else(|| self.think_model.clone());
3587
3588 let trimmed_input = user_input.trim();
3589
3590 if trimmed_input == "/model" || trimmed_input.starts_with("/model ") {
3591 let arg_text = trimmed_input.strip_prefix("/model").unwrap_or("").trim();
3592 let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3593 Ok(self.runtime_model_status_report(&config).await)
3594 } else if let Some(list_args) = arg_text.strip_prefix("list").map(str::trim) {
3595 let loaded_only = if list_args.is_empty()
3596 || list_args.eq_ignore_ascii_case("available")
3597 {
3598 false
3599 } else if list_args.eq_ignore_ascii_case("loaded") {
3600 true
3601 } else {
3602 for chunk in chunk_text(&format!("Usage: {}", Self::model_command_usage()), 8) {
3603 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3604 }
3605 let _ = tx.send(InferenceEvent::Done).await;
3606 return Ok(());
3607 };
3608 let provider = self.engine.provider_name().await;
3609 self.format_provider_model_inventory(
3610 &provider,
3611 crate::agent::provider::ProviderModelKind::Coding,
3612 loaded_only,
3613 )
3614 .await
3615 } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3616 if load_args.is_empty() {
3617 Err(format!("Usage: {}", Self::model_command_usage()))
3618 } else {
3619 let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3620 self.load_runtime_model_now(&tx, &model_id, "coding", context_length)
3621 .await
3622 }
3623 } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3624 let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3625 self.unload_runtime_model_now(&tx, target.as_deref(), "coding", unload_all)
3626 .await
3627 } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3628 if model_id.is_empty() {
3629 Err(format!("Usage: {}", Self::model_command_usage()))
3630 } else {
3631 crate::agent::config::set_preferred_coding_model(Some(model_id)).map(|_| {
3632 format!(
3633 "Saved preferred coding model `{}` in `.hematite/settings.json`. Use `/model load {}` now or restart Hematite to let startup policy load it automatically.",
3634 model_id, model_id
3635 )
3636 })
3637 }
3638 } else if matches!(arg_text, "clear" | "clear-preference") {
3639 crate::agent::config::set_preferred_coding_model(None)
3640 .map(|_| "Cleared the saved preferred coding model.".to_string())
3641 } else {
3642 Err(format!("Usage: {}", Self::model_command_usage()))
3643 };
3644
3645 for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3646 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3647 }
3648 let _ = tx.send(InferenceEvent::Done).await;
3649 return Ok(());
3650 }
3651
3652 if trimmed_input == "/embed" || trimmed_input.starts_with("/embed ") {
3653 let arg_text = trimmed_input.strip_prefix("/embed").unwrap_or("").trim();
3654 let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3655 Ok(self.runtime_model_status_report(&config).await)
3656 } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3657 if load_args.is_empty() {
3658 Err(format!("Usage: {}", Self::embed_command_usage()))
3659 } else {
3660 let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3661 if context_length.is_some() {
3662 Err("`/embed load` does not accept `--ctx`. Embedding models do not use a chat context window here.".to_string())
3663 } else {
3664 self.load_runtime_model_now(&tx, &model_id, "embed", None)
3665 .await
3666 }
3667 }
3668 } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3669 let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3670 if unload_all {
3671 Err("`/embed unload` supports the current embed model or an explicit embed model ID, not `all`.".to_string())
3672 } else {
3673 self.unload_runtime_model_now(&tx, target.as_deref(), "embed", false)
3674 .await
3675 }
3676 } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3677 if model_id.is_empty() {
3678 Err(format!("Usage: {}", Self::embed_command_usage()))
3679 } else {
3680 crate::agent::config::set_preferred_embed_model(Some(model_id)).map(|_| {
3681 format!(
3682 "Saved preferred embed model `{}` in `.hematite/settings.json`. Use `/embed load {}` now or restart Hematite to let startup policy load it automatically.",
3683 model_id, model_id
3684 )
3685 })
3686 }
3687 } else if matches!(arg_text, "clear" | "clear-preference") {
3688 crate::agent::config::set_preferred_embed_model(None)
3689 .map(|_| "Cleared the saved preferred embed model.".to_string())
3690 } else {
3691 Err(format!("Usage: {}", Self::embed_command_usage()))
3692 };
3693
3694 for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3695 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3696 }
3697 let _ = tx.send(InferenceEvent::Done).await;
3698 return Ok(());
3699 }
3700
3701 if user_input.trim() == "/lsp" {
3703 let mut lsp = self.lsp_manager.lock().await;
3704 match lsp.start_servers().await {
3705 Ok(_) => {
3706 let _ = tx
3707 .send(InferenceEvent::MutedToken(
3708 "LSP: Servers Initialized OK.".to_string(),
3709 ))
3710 .await;
3711 }
3712 Err(e) => {
3713 let _ = tx
3714 .send(InferenceEvent::Error(format!(
3715 "LSP: Failed to start servers - {}",
3716 e
3717 )))
3718 .await;
3719 }
3720 }
3721 let _ = tx.send(InferenceEvent::Done).await;
3722 return Ok(());
3723 }
3724
3725 if user_input.trim() == "/runtime-refresh" {
3726 match self
3727 .refresh_runtime_profile_and_report(&tx, "manual_command")
3728 .await
3729 {
3730 Some((model_id, context_length, changed)) => {
3731 let msg = if changed {
3732 format!(
3733 "Runtime profile refreshed. Model: {} | CTX: {}",
3734 model_id, context_length
3735 )
3736 } else {
3737 format!(
3738 "Runtime profile unchanged. Model: {} | CTX: {}",
3739 model_id, context_length
3740 )
3741 };
3742 for chunk in chunk_text(&msg, 8) {
3743 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3744 }
3745 }
3746 None => {
3747 let provider_name = self.engine.provider_name().await;
3748 let endpoint = crate::runtime::session_endpoint_url(&self.engine.base_url);
3749 let alternative =
3750 crate::runtime::detect_alternative_provider(&provider_name).await;
3751 let mut message = format!(
3752 "Runtime refresh failed: {} could not be read at {}.",
3753 provider_name, endpoint
3754 );
3755 if let Some((alt_name, alt_url)) = alternative {
3756 message.push_str(&format!(
3757 " Reachable alternative detected: {} ({})",
3758 alt_name, alt_url
3759 ));
3760 }
3761 let _ = tx.send(InferenceEvent::Error(message)).await;
3762 }
3763 }
3764 let _ = tx.send(InferenceEvent::Done).await;
3765 return Ok(());
3766 }
3767
3768 if user_input.trim() == "/ask" {
3769 self.set_workflow_mode(WorkflowMode::Ask);
3770 for chunk in chunk_text(
3771 "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
3772 8,
3773 ) {
3774 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3775 }
3776 let _ = tx.send(InferenceEvent::Done).await;
3777 return Ok(());
3778 }
3779
3780 if user_input.trim() == "/code" {
3781 self.set_workflow_mode(WorkflowMode::Code);
3782 let mut message =
3783 "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
3784 if let Some(plan) = self.current_plan_summary() {
3785 message.push_str(&format!(" Current plan: {plan}."));
3786 }
3787 for chunk in chunk_text(&message, 8) {
3788 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3789 }
3790 let _ = tx.send(InferenceEvent::Done).await;
3791 return Ok(());
3792 }
3793
3794 if user_input.trim() == "/architect" {
3795 self.set_workflow_mode(WorkflowMode::Architect);
3796 let mut message =
3797 "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();
3798 if let Some(plan) = self.current_plan_summary() {
3799 message.push_str(&format!(" Existing plan: {plan}."));
3800 }
3801 for chunk in chunk_text(&message, 8) {
3802 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3803 }
3804 let _ = tx.send(InferenceEvent::Done).await;
3805 return Ok(());
3806 }
3807
3808 if user_input.trim() == "/read-only" {
3809 self.set_workflow_mode(WorkflowMode::ReadOnly);
3810 for chunk in chunk_text(
3811 "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
3812 8,
3813 ) {
3814 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3815 }
3816 let _ = tx.send(InferenceEvent::Done).await;
3817 return Ok(());
3818 }
3819
3820 if user_input.trim() == "/auto" {
3821 self.set_workflow_mode(WorkflowMode::Auto);
3822 for chunk in chunk_text(
3823 "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
3824 8,
3825 ) {
3826 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3827 }
3828 let _ = tx.send(InferenceEvent::Done).await;
3829 return Ok(());
3830 }
3831
3832 if user_input.trim() == "/chat" {
3833 self.set_workflow_mode(WorkflowMode::Chat);
3834 let _ = tx.send(InferenceEvent::Done).await;
3835 return Ok(());
3836 }
3837
3838 if user_input.trim() == "/teach" {
3839 self.set_workflow_mode(WorkflowMode::Teach);
3840 for chunk in chunk_text(
3841 "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.",
3842 8,
3843 ) {
3844 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3845 }
3846 let _ = tx.send(InferenceEvent::Done).await;
3847 return Ok(());
3848 }
3849
3850 if user_input.trim() == "/reroll" {
3851 let soul = crate::ui::hatch::generate_soul_random();
3852 self.snark = soul.snark;
3853 self.chaos = soul.chaos;
3854 self.soul_personality = soul.personality.clone();
3855 let species = soul.species.clone();
3860 if let Some(eng) = Arc::get_mut(&mut self.engine) {
3861 eng.species = species.clone();
3862 }
3863 let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
3864 let _ = tx
3865 .send(InferenceEvent::SoulReroll {
3866 species: soul.species.clone(),
3867 rarity: soul.rarity.label().to_string(),
3868 shiny: soul.shiny,
3869 personality: soul.personality.clone(),
3870 })
3871 .await;
3872 for chunk in chunk_text(
3873 &format!(
3874 "A new companion awakens!\n[{}{}] {} — \"{}\"",
3875 soul.rarity.label(),
3876 shiny_tag,
3877 soul.species,
3878 soul.personality
3879 ),
3880 8,
3881 ) {
3882 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3883 }
3884 let _ = tx.send(InferenceEvent::Done).await;
3885 return Ok(());
3886 }
3887
3888 if user_input.trim() == "/agent" {
3889 self.set_workflow_mode(WorkflowMode::Auto);
3890 let _ = tx.send(InferenceEvent::Done).await;
3891 return Ok(());
3892 }
3893
3894 let implement_plan_alias = user_input.trim() == "/implement-plan";
3895 if implement_plan_alias
3896 && !self
3897 .session_memory
3898 .current_plan
3899 .as_ref()
3900 .map(|plan| plan.has_signal())
3901 .unwrap_or(false)
3902 {
3903 for chunk in chunk_text(
3904 "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
3905 8,
3906 ) {
3907 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3908 }
3909 let _ = tx.send(InferenceEvent::Done).await;
3910 return Ok(());
3911 }
3912
3913 let mut effective_user_input = if implement_plan_alias {
3914 self.set_workflow_mode(WorkflowMode::Code);
3915 implement_current_plan_prompt().to_string()
3916 } else {
3917 user_input.trim().to_string()
3918 };
3919 if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
3920 self.set_workflow_mode(mode);
3921 effective_user_input = rest.to_string();
3922 }
3923 let transcript_user_input = if implement_plan_alias {
3924 transcript_user_turn_text(user_turn, "/implement-plan")
3925 } else {
3926 transcript_user_turn_text(user_turn, &effective_user_input)
3927 };
3928 effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
3929 self.register_at_file_mentions(user_input).await;
3932 let implement_current_plan = self.workflow_mode == WorkflowMode::Code
3933 && is_current_plan_execution_request(&effective_user_input)
3934 && self
3935 .session_memory
3936 .current_plan
3937 .as_ref()
3938 .map(|plan| plan.has_signal())
3939 .unwrap_or(false);
3940 let explicit_search_request = is_explicit_web_search_request(&effective_user_input);
3941 let mut grounded_research_results: Option<String> = None;
3942 self.plan_execution_active
3943 .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
3944 let _plan_execution_guard = PlanExecutionGuard {
3945 flag: self.plan_execution_active.clone(),
3946 };
3947 let task_progress_before = if implement_current_plan {
3948 read_task_checklist_progress()
3949 } else {
3950 None
3951 };
3952 let current_plan_pass = if implement_current_plan {
3953 self.plan_execution_pass_depth
3954 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
3955 + 1
3956 } else {
3957 0
3958 };
3959 let _plan_execution_pass_guard = implement_current_plan.then(|| PlanExecutionPassGuard {
3960 depth: self.plan_execution_pass_depth.clone(),
3961 });
3962 let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
3963
3964 if should_use_turn_scoped_investigation_mode(self.workflow_mode, intent.primary_class) {
3966 let _ = tx
3967 .send(InferenceEvent::Thought(
3968 "Seamless search detected: using investigation mode for this turn...".into(),
3969 ))
3970 .await;
3971 }
3972
3973 if let Some(answer_kind) = intent.direct_answer {
3975 match answer_kind {
3976 DirectAnswerKind::About => {
3977 let response = build_about_answer();
3978 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3979 .await;
3980 return Ok(());
3981 }
3982 DirectAnswerKind::LanguageCapability => {
3983 let response = build_language_capability_answer();
3984 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3985 .await;
3986 return Ok(());
3987 }
3988 DirectAnswerKind::UnsafeWorkflowPressure => {
3989 let response = build_unsafe_workflow_pressure_answer();
3990 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3991 .await;
3992 return Ok(());
3993 }
3994 DirectAnswerKind::SessionMemory => {
3995 let response = build_session_memory_answer();
3996 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
3997 .await;
3998 return Ok(());
3999 }
4000 DirectAnswerKind::RecoveryRecipes => {
4001 let response = build_recovery_recipes_answer();
4002 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4003 .await;
4004 return Ok(());
4005 }
4006 DirectAnswerKind::McpLifecycle => {
4007 let response = build_mcp_lifecycle_answer();
4008 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4009 .await;
4010 return Ok(());
4011 }
4012 DirectAnswerKind::AuthorizationPolicy => {
4013 let response = build_authorization_policy_answer();
4014 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4015 .await;
4016 return Ok(());
4017 }
4018 DirectAnswerKind::ToolClasses => {
4019 let response = build_tool_classes_answer();
4020 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4021 .await;
4022 return Ok(());
4023 }
4024 DirectAnswerKind::ToolRegistryOwnership => {
4025 let response = build_tool_registry_ownership_answer();
4026 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4027 .await;
4028 return Ok(());
4029 }
4030 DirectAnswerKind::SessionResetSemantics => {
4031 let response = build_session_reset_semantics_answer();
4032 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4033 .await;
4034 return Ok(());
4035 }
4036 DirectAnswerKind::ProductSurface => {
4037 let response = build_product_surface_answer();
4038 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4039 .await;
4040 return Ok(());
4041 }
4042 DirectAnswerKind::ReasoningSplit => {
4043 let response = build_reasoning_split_answer();
4044 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4045 .await;
4046 return Ok(());
4047 }
4048 DirectAnswerKind::Identity => {
4049 let response = build_identity_answer();
4050 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4051 .await;
4052 return Ok(());
4053 }
4054 DirectAnswerKind::WorkflowModes => {
4055 let response = build_workflow_modes_answer();
4056 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4057 .await;
4058 return Ok(());
4059 }
4060 DirectAnswerKind::GemmaNative => {
4061 let response = build_gemma_native_answer();
4062 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4063 .await;
4064 return Ok(());
4065 }
4066 DirectAnswerKind::GemmaNativeSettings => {
4067 let response = build_gemma_native_settings_answer();
4068 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4069 .await;
4070 return Ok(());
4071 }
4072 DirectAnswerKind::VerifyProfiles => {
4073 let response = build_verify_profiles_answer();
4074 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4075 .await;
4076 return Ok(());
4077 }
4078 DirectAnswerKind::Toolchain => {
4079 let lower = effective_user_input.to_lowercase();
4080 let topic = if (lower.contains("voice output") || lower.contains("voice"))
4081 && (lower.contains("lag")
4082 || lower.contains("behind visible text")
4083 || lower.contains("latency"))
4084 {
4085 "voice_latency_plan"
4086 } else {
4087 "all"
4088 };
4089 let response =
4090 crate::tools::toolchain::describe_toolchain(&serde_json::json!({
4091 "topic": topic,
4092 "question": effective_user_input,
4093 }))
4094 .await
4095 .unwrap_or_else(|e| format!("Error: {}", e));
4096 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4097 .await;
4098 return Ok(());
4099 }
4100 DirectAnswerKind::HostInspection => {
4101 let topics = all_host_inspection_topics(&effective_user_input);
4102 let response = if topics.len() >= 2 {
4103 let mut combined = Vec::new();
4104 for topic in topics {
4105 let args =
4106 host_inspection_args_from_prompt(topic, &effective_user_input);
4107 let output = crate::tools::host_inspect::inspect_host(&args)
4108 .await
4109 .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
4110 combined.push(format!("# Topic: {topic}\n{output}"));
4111 }
4112 combined.join("\n\n---\n\n")
4113 } else {
4114 let topic = preferred_host_inspection_topic(&effective_user_input)
4115 .unwrap_or("summary");
4116 let args = host_inspection_args_from_prompt(topic, &effective_user_input);
4117 crate::tools::host_inspect::inspect_host(&args)
4118 .await
4119 .unwrap_or_else(|e| format!("Error: {e}"))
4120 };
4121
4122 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4123 .await;
4124 return Ok(());
4125 }
4126 DirectAnswerKind::ArchitectSessionResetPlan => {
4127 let plan = build_architect_session_reset_plan();
4128 let response = plan.to_markdown();
4129 let _ = crate::tools::plan::save_plan_handoff(&plan);
4130 self.session_memory.current_plan = Some(plan);
4131 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4132 .await;
4133 return Ok(());
4134 }
4135 }
4136 }
4137
4138 if matches!(
4139 self.workflow_mode,
4140 WorkflowMode::Ask | WorkflowMode::ReadOnly
4141 ) && looks_like_mutation_request(&effective_user_input)
4142 {
4143 let response = build_mode_redirect_answer(self.workflow_mode);
4144 self.history.push(ChatMessage::user(&effective_user_input));
4145 self.history.push(ChatMessage::assistant_text(&response));
4146 self.transcript.log_user(&transcript_user_input);
4147 self.transcript.log_agent(&response);
4148 for chunk in chunk_text(&response, 8) {
4149 if !chunk.is_empty() {
4150 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4151 }
4152 }
4153 let _ = tx.send(InferenceEvent::Done).await;
4154 self.trim_history(80);
4155 self.refresh_session_memory();
4156 self.save_session();
4157 return Ok(());
4158 }
4159
4160 if user_input.trim() == "/think" {
4161 self.think_mode = Some(true);
4162 for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
4163 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4164 }
4165 let _ = tx.send(InferenceEvent::Done).await;
4166 return Ok(());
4167 }
4168 if user_input.trim() == "/no_think" {
4169 self.think_mode = Some(false);
4170 for chunk in chunk_text(
4171 "Think mode: OFF — fast mode enabled (no chain-of-thought).",
4172 8,
4173 ) {
4174 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4175 }
4176 let _ = tx.send(InferenceEvent::Done).await;
4177 return Ok(());
4178 }
4179
4180 if user_input.trim_start().starts_with("/pin ") {
4182 let path = user_input.trim_start()[5..].trim();
4183 match std::fs::read_to_string(path) {
4184 Ok(content) => {
4185 self.pinned_files
4186 .lock()
4187 .await
4188 .insert(path.to_string(), content);
4189 let msg = format!(
4190 "Pinned: {} — this file is now locked in model context.",
4191 path
4192 );
4193 for chunk in chunk_text(&msg, 8) {
4194 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4195 }
4196 }
4197 Err(e) => {
4198 let _ = tx
4199 .send(InferenceEvent::Error(format!(
4200 "Failed to pin {}: {}",
4201 path, e
4202 )))
4203 .await;
4204 }
4205 }
4206 let _ = tx.send(InferenceEvent::Done).await;
4207 return Ok(());
4208 }
4209
4210 if user_input.trim_start().starts_with("/unpin ") {
4212 let path = user_input.trim_start()[7..].trim();
4213 if self.pinned_files.lock().await.remove(path).is_some() {
4214 let msg = format!("Unpinned: {} — file removed from active context.", path);
4215 for chunk in chunk_text(&msg, 8) {
4216 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4217 }
4218 } else {
4219 let _ = tx
4220 .send(InferenceEvent::Error(format!(
4221 "File {} was not pinned.",
4222 path
4223 )))
4224 .await;
4225 }
4226 let _ = tx.send(InferenceEvent::Done).await;
4227 return Ok(());
4228 }
4229
4230 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
4234 if let Some(root) = extract_sovereign_scaffold_root(&effective_user_input) {
4235 if std::fs::create_dir_all(&root).is_ok() {
4236 let targets = default_sovereign_scaffold_targets(&effective_user_input);
4237 let _ = seed_sovereign_scaffold_files(&root, &targets);
4238 let plan = build_sovereign_scaffold_handoff(&effective_user_input, &targets);
4239 let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &plan);
4240 let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
4241 let _ = write_sovereign_handoff_markdown(&root, &effective_user_input, &plan);
4242 self.pending_teleport_handoff = None;
4243 self.latest_target_dir = Some(root.to_string_lossy().to_string());
4244 let response = format!(
4245 "Created the sovereign project root at `{}` and wrote a local handoff. Teleporting now so the next session can continue implementation inside that project.",
4246 root.display()
4247 );
4248 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4249 .await;
4250 return Ok(());
4251 }
4252 }
4253 }
4254
4255 let tiny_context_mode = self.engine.current_context_length() <= 8_192;
4256 let mut base_prompt = self.engine.build_system_prompt(
4257 self.snark,
4258 self.chaos,
4259 self.brief,
4260 self.professional,
4261 &self.tools,
4262 self.reasoning_history.as_deref(),
4263 None,
4264 &mcp_tools,
4265 );
4266 if !tiny_context_mode {
4267 if let Some(hint) = &config.context_hint {
4268 if !hint.trim().is_empty() {
4269 base_prompt.push_str(&format!(
4270 "\n\n# Project Context (from .hematite/settings.json)\n{}",
4271 hint
4272 ));
4273 }
4274 }
4275 if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
4276 &crate::tools::file_ops::workspace_root(),
4277 ) {
4278 base_prompt.push_str(&format!("\n\n{}", profile_block));
4279 }
4280 if let Some(strategy_block) =
4281 crate::agent::workspace_profile::profile_strategy_prompt_block(
4282 &crate::tools::file_ops::workspace_root(),
4283 )
4284 {
4285 base_prompt.push_str(&format!("\n\n{}", strategy_block));
4286 }
4287 if let Some(ref l1) = self.l1_context {
4289 base_prompt.push_str(&format!("\n\n{}", l1));
4290 }
4291 if let Some(ref repo_map_block) = self.repo_map {
4292 base_prompt.push_str(&format!("\n\n{}", repo_map_block));
4293 }
4294 }
4295 let grounded_trace_mode = intent.grounded_trace_mode
4296 || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
4297 let capability_mode =
4298 intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
4299 let toolchain_mode =
4300 intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
4301 let host_inspection_mode = if intent.host_inspection_mode {
4306 let api_url = self.engine.base_url.clone();
4307 let query = effective_user_input.clone();
4308 let embed_class = tokio::time::timeout(
4309 std::time::Duration::from_millis(600),
4310 crate::agent::intent_embed::classify_intent(&query, &api_url),
4311 )
4312 .await
4313 .unwrap_or(crate::agent::intent_embed::IntentClass::Ambiguous);
4314 !matches!(
4315 embed_class,
4316 crate::agent::intent_embed::IntentClass::Advisory
4317 )
4318 } else {
4319 false
4320 };
4321 let maintainer_workflow_mode = intent.maintainer_workflow_mode
4322 || preferred_maintainer_workflow(&effective_user_input).is_some();
4323 let fix_plan_mode =
4324 preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
4325 let architecture_overview_mode = intent.architecture_overview_mode;
4326 let capability_needs_repo = intent.capability_needs_repo;
4327 let research_mode = intent.primary_class == QueryIntentClass::Research
4328 && intent.direct_answer.is_none()
4329 && !(capability_mode && !capability_needs_repo);
4330 let mut system_msg = build_system_with_corrections(
4331 &base_prompt,
4332 &self.correction_hints,
4333 &self.gpu_state,
4334 &self.git_state,
4335 &config,
4336 );
4337 if !tiny_context_mode && research_mode {
4338 system_msg.push_str(
4339 "\n\n# RESEARCH MODE\n\
4340 This turn is an investigation into external technical information.\n\
4341 Prioritize using the `research_web` tool to find the most current and authoritative data.\n\
4342 When providing information, ground your answer in the search results and cite your sources if possible.\n\
4343 If the user's question involves specific versions or recent releases (e.g., Rust compiler), use the web to verify the exact state.\n"
4344 );
4345 }
4346 if tiny_context_mode {
4347 system_msg.push_str(
4348 "\n\n# TINY CONTEXT TURN MODE\n\
4349 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
4350 );
4351 }
4352 if !tiny_context_mode && grounded_trace_mode {
4353 system_msg.push_str(
4354 "\n\n# GROUNDED TRACE MODE\n\
4355 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
4356 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
4357 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
4358 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
4359 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
4360 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
4361 Do not invent names such as synthetic channels or subsystems.\n\
4362 If a detail is not verified from the code or tool output, say `uncertain`.\n\
4363 For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
4364 );
4365 }
4366 if !tiny_context_mode && capability_mode {
4367 }
4369 if !tiny_context_mode && toolchain_mode {
4370 }
4372 if !tiny_context_mode && host_inspection_mode {
4373 }
4375 if !tiny_context_mode && fix_plan_mode {
4376 system_msg.push_str(
4377 "\n\n# FIX PLAN MODE\n\
4378 This turn is a workstation remediation question, not just a diagnosis question.\n\
4379 Call `inspect_host` with `topic=fix_plan` first.\n\
4380 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
4381 Keep the answer grounded, stepwise, and approval-aware.\n"
4382 );
4383 }
4384 if !tiny_context_mode && maintainer_workflow_mode {
4385 system_msg.push_str(
4386 "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
4387 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
4388 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
4389 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\
4390 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"
4391 );
4392 }
4393 if !tiny_context_mode && architecture_overview_mode {
4396 system_msg.push_str(
4397 "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
4398 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
4399 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\
4400 Preserve grounded tool output rather than restyling it into a larger answer.\n"
4401 );
4402 }
4403
4404 system_msg.push_str(&format!(
4406 "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
4407 self.workflow_mode.label()
4408 ));
4409 if tiny_context_mode {
4410 system_msg
4411 .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
4412 } else {
4413 }
4414 if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
4415 system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
4416 system_msg.push_str(architect_handoff_contract());
4417 system_msg.push('\n');
4418 }
4419 if !tiny_context_mode && is_scaffold_request(&effective_user_input) {
4420 system_msg.push_str(scaffold_protocol());
4421 }
4422 if !tiny_context_mode {
4423 let workspace_root = crate::tools::file_ops::workspace_root();
4424 let skill_discovery =
4425 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
4426 if let Some(bodies) = crate::agent::instructions::render_active_skill_bodies(
4427 &skill_discovery,
4428 &effective_user_input,
4429 8_000,
4430 ) {
4431 system_msg.push_str(&format!("\n\n{}", bodies));
4432 }
4433 if let Some(forced_body) = self.pending_skill_inject.take() {
4435 system_msg.push_str(&format!(
4436 "\n\n# Active Skill Instructions\n\n{}",
4437 forced_body
4438 ));
4439 }
4440 }
4441 if !tiny_context_mode && implement_current_plan {
4442 system_msg.push_str(
4443 "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
4444 The user explicitly asked you to implement the current saved plan.\n\
4445 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
4446 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
4447 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
4448 If the saved plan explicitly calls for `research_web` or `fetch_docs`, do that research first, then return to the target files.\n\
4449 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",
4450 );
4451 if let Some(plan) = self.session_memory.current_plan.as_ref() {
4452 if !plan.target_files.is_empty() {
4453 system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
4454 for path in &plan.target_files {
4455 system_msg.push_str(&format!("- {}\n", path));
4456 }
4457 }
4458 }
4459 }
4460 if !tiny_context_mode {
4461 let pinned = self.pinned_files.lock().await;
4462 if !pinned.is_empty() {
4463 system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
4464 system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
4465 for (path, content) in pinned.iter() {
4466 system_msg.push_str(&format!("## FILE: {}\n```\n{}\n```\n\n", path, content));
4467 }
4468 }
4469 }
4470 if !tiny_context_mode {
4471 self.append_session_handoff(&mut system_msg);
4472 }
4473 let mut final_system_msg = if self.workflow_mode.is_chat() {
4475 self.build_chat_system_prompt()
4476 } else {
4477 system_msg
4478 };
4479
4480 if !tiny_context_mode
4481 && matches!(self.workflow_mode, WorkflowMode::Code | WorkflowMode::Auto)
4482 {
4483 let task_path = std::path::Path::new(".hematite/TASK.md");
4484 if task_path.exists() {
4485 if let Ok(content) = std::fs::read_to_string(task_path) {
4486 let snippet = if content.lines().count() > 50 {
4487 content.lines().take(50).collect::<Vec<_>>().join("\n")
4488 + "\n... (truncated)"
4489 } else {
4490 content
4491 };
4492 final_system_msg.push_str("\n\n# CURRENT TASK STATUS (.hematite/TASK.md)\n");
4493 final_system_msg.push_str("Update this file via `edit_file` to check off `[x]` items as you complete them.\n");
4494 final_system_msg.push_str("```markdown\n");
4495 final_system_msg.push_str(&snippet);
4496 final_system_msg.push_str("\n```\n");
4497 }
4498 }
4499 }
4500
4501 if !tiny_context_mode {
4503 let tasks = crate::agent::tasks::load();
4504 if let Some(block) = crate::agent::tasks::render_prompt_block(&tasks) {
4505 final_system_msg.push_str("\n\n");
4506 final_system_msg.push_str(&block);
4507 }
4508 }
4509
4510 if !tiny_context_mode && !self.workflow_mode.is_chat() {
4512 if let Some(ref block) = self.shell_history_block {
4513 final_system_msg.push_str("\n\n");
4514 final_system_msg.push_str(block);
4515 }
4516 }
4517
4518 let system_msg = final_system_msg;
4519 if self.history.is_empty() || self.history[0].role != "system" {
4520 self.history.insert(0, ChatMessage::system(&system_msg));
4521 } else {
4522 self.history[0] = ChatMessage::system(&system_msg);
4523 }
4524
4525 self.cancel_token
4527 .store(false, std::sync::atomic::Ordering::SeqCst);
4528
4529 self.reasoning_history = None;
4532
4533 let is_gemma =
4534 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
4535 let user_content = match self.think_mode {
4536 Some(true) => format!("/think\n{}", effective_user_input),
4537 Some(false) => format!("/no_think\n{}", effective_user_input),
4538 None if !is_gemma
4543 && !self.workflow_mode.is_chat()
4544 && !is_quick_tool_request(&effective_user_input) =>
4545 {
4546 format!("/think\n{}", effective_user_input)
4547 }
4548 None => effective_user_input.clone(),
4549 };
4550 if let Some(image) = user_turn.attached_image.as_ref() {
4551 let image_url =
4552 crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
4553 .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
4554 self.history
4555 .push(ChatMessage::user_with_image(&user_content, &image_url));
4556 } else {
4557 self.history.push(ChatMessage::user(&user_content));
4558 }
4559 self.transcript.log_user(&transcript_user_input);
4560
4561 let vein_docs_only = self.vein_docs_only_mode();
4565 let allow_vein_context = !self.workflow_mode.is_chat()
4566 || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
4567 let (vein_context, vein_paths) = if allow_vein_context {
4568 self.refresh_vein_index();
4569 let _ = tx
4570 .send(InferenceEvent::VeinStatus {
4571 file_count: self.vein.file_count(),
4572 embedded_count: self.vein.embedded_chunk_count(),
4573 docs_only: vein_docs_only,
4574 })
4575 .await;
4576 match self.build_vein_context(&effective_user_input) {
4577 Some((ctx, paths)) => (Some(ctx), paths),
4578 None => (None, Vec::new()),
4579 }
4580 } else {
4581 (None, Vec::new())
4582 };
4583 {
4585 let mut tracker = self.diff_tracker.lock().await;
4586 tracker.reset();
4587 }
4588
4589 let heartbeat = crate::agent::policy::ToolchainHeartbeat::capture();
4591 self.last_heartbeat = Some(heartbeat.clone());
4592
4593 if !vein_paths.is_empty() {
4594 let _ = tx
4595 .send(InferenceEvent::VeinContext { paths: vein_paths })
4596 .await;
4597 }
4598
4599 let routed_model = route_model(
4601 &effective_user_input,
4602 effective_fast.as_deref(),
4603 effective_think.as_deref(),
4604 )
4605 .map(|s| s.to_string());
4606
4607 let mut loop_intervention: Option<String> = None;
4608
4609 {
4616 let topics = all_host_inspection_topics(&effective_user_input);
4617 if topics.len() >= 2 {
4618 let _ = tx
4619 .send(InferenceEvent::Thought(format!(
4620 "Harness pre-run: {} host inspection topics detected — running all before model turn.",
4621 topics.len()
4622 )))
4623 .await;
4624
4625 let topic_list = topics.join(", ");
4626 let mut combined = format!(
4627 "## HARNESS PRE-RUN RESULTS\n\
4628 The harness already ran inspect_host for the following topics: {topic_list}.\n\
4629 Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
4630 );
4631
4632 let mut tool_calls = Vec::new();
4633 let mut tool_msgs = Vec::new();
4634
4635 for topic in &topics {
4636 let call_id = format!("prerun_{topic}");
4637 let mut args_val =
4638 host_inspection_args_from_prompt(topic, &effective_user_input);
4639 args_val
4640 .as_object_mut()
4641 .unwrap()
4642 .insert("max_entries".to_string(), Value::from(20));
4643 let _args_str = serde_json::to_string(&args_val).unwrap_or_default();
4644
4645 tool_calls.push(crate::agent::types::ToolCallResponse {
4646 id: call_id.clone(),
4647 call_type: "function".to_string(),
4648 function: crate::agent::types::ToolCallFn {
4649 name: "inspect_host".to_string(),
4650 arguments: args_val.clone(),
4651 },
4652 index: None,
4653 });
4654
4655 let label = format!("### inspect_host(topic=\"{topic}\")\n");
4656 let _ = tx
4657 .send(InferenceEvent::ToolCallStart {
4658 id: call_id.clone(),
4659 name: "inspect_host".to_string(),
4660 args: format!("inspect host {topic}"),
4661 })
4662 .await;
4663
4664 match crate::tools::host_inspect::inspect_host(&args_val).await {
4665 Ok(out) => {
4666 let _ = tx
4667 .send(InferenceEvent::ToolCallResult {
4668 id: call_id.clone(),
4669 name: "inspect_host".to_string(),
4670 result: out.chars().take(300).collect::<String>() + "...",
4671 is_error: false,
4672 })
4673 .await;
4674 combined.push_str(&label);
4675 combined.push_str(&out);
4676 combined.push_str("\n\n");
4677 tool_msgs.push(ChatMessage::tool_result_for_model(
4678 &call_id,
4679 "inspect_host",
4680 &out,
4681 &self.engine.current_model(),
4682 ));
4683 }
4684 Err(e) => {
4685 let err_msg = format!("Error: {e}");
4686 combined.push_str(&label);
4687 combined.push_str(&err_msg);
4688 combined.push_str("\n\n");
4689 tool_msgs.push(ChatMessage::tool_result_for_model(
4690 &call_id,
4691 "inspect_host",
4692 &err_msg,
4693 &self.engine.current_model(),
4694 ));
4695 }
4696 }
4697 }
4698
4699 self.history
4701 .push(ChatMessage::assistant_tool_calls("", tool_calls));
4702 for msg in tool_msgs {
4703 self.history.push(msg);
4704 }
4705
4706 loop_intervention = Some(combined);
4707 }
4708 }
4709
4710 if loop_intervention.is_none() && research_mode {
4716 let search_query = extract_explicit_web_search_query(&effective_user_input)
4718 .unwrap_or_else(|| effective_user_input.trim().to_string());
4719
4720 let _ = tx
4721 .send(InferenceEvent::Thought(
4722 "Research pre-run: executing search before model turn to ground the answer..."
4723 .into(),
4724 ))
4725 .await;
4726
4727 let call_id = "prerun_research".to_string();
4728 let args = serde_json::json!({ "query": search_query });
4729
4730 let _ = tx
4731 .send(InferenceEvent::ToolCallStart {
4732 id: call_id.clone(),
4733 name: "research_web".to_string(),
4734 args: format!("research_web: {}", search_query),
4735 })
4736 .await;
4737
4738 match crate::tools::research::execute_search(&args, config.searx_url.clone()).await {
4739 Ok(results)
4740 if !results.is_empty() && !results.contains("No search results found") =>
4741 {
4742 grounded_research_results = Some(results.clone());
4743 let _ = tx
4744 .send(InferenceEvent::ToolCallResult {
4745 id: call_id.clone(),
4746 name: "research_web".to_string(),
4747 result: results.chars().take(300).collect::<String>() + "...",
4748 is_error: false,
4749 })
4750 .await;
4751
4752 loop_intervention = Some(format!(
4753 "## RESEARCH PRE-RUN RESULTS\n\
4754 The harness already ran `research_web` for your query.\n\
4755 Use the search results above to answer the user's question with grounded, factual information.\n\
4756 Do NOT re-run `research_web` unless you need additional detail.\n\
4757 Do NOT hallucinate or guess — base your answer entirely on the search results.\n\n\
4758 {}",
4759 results
4760 ));
4761 }
4762 Ok(_) | Err(_) => {
4763 let _ = tx
4765 .send(InferenceEvent::ToolCallResult {
4766 id: call_id.clone(),
4767 name: "research_web".to_string(),
4768 result: "No results found — model will attempt its own search.".into(),
4769 is_error: true,
4770 })
4771 .await;
4772 }
4773 }
4774 }
4775
4776 if loop_intervention.is_none() {
4783 if let Some(fix_ctx) = self.pending_fix_context.take() {
4784 loop_intervention = Some(format!(
4785 "FIX MODE — The build is currently failing. Fix ONLY the error below. \
4786 Do not refactor, add features, or touch unrelated code. \
4787 After each edit call `verify_build` to check if the error is resolved. \
4788 Stop as soon as the build is green.\n\n\
4789 ## Current Build Error\n```\n{}\n```",
4790 fix_ctx.trim()
4791 ));
4792 }
4793 }
4794
4795 if loop_intervention.is_none() && needs_github_ops(&effective_user_input) {
4796 loop_intervention = Some(
4797 "GITHUB TOOL NOTICE: This query is about GitHub (PRs, issues, CI runs, or checks). \
4798 Use the `github_ops` tool — never call `gh` via `shell`. \
4799 For a quick overview, try `/pr` (PR status), `/ci` (CI status), or `/issue` (issues). \
4800 The model should call `github_ops` with the appropriate `action` field."
4801 .to_string(),
4802 );
4803 }
4804
4805 if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
4806 loop_intervention = Some(
4807 "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
4808 Do NOT answer from training-data memory — memory answers for math are guesses. \
4809 Use `run_code` to compute the real result and return the actual output. \
4810 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
4811 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
4812 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
4813 Write the code, run it, return the result."
4814 .to_string(),
4815 );
4816 }
4817
4818 if loop_intervention.is_none() && intent.surgical_filesystem_mode {
4820 loop_intervention = Some(
4821 "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
4822 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
4823 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
4824 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
4825 .to_string(),
4826 );
4827 }
4828
4829 if loop_intervention.is_none()
4834 && self.workflow_mode == WorkflowMode::Auto
4835 && is_scaffold_request(&effective_user_input)
4836 && !implement_current_plan
4837 {
4838 loop_intervention = Some(
4839 "AUTO-ARCHITECT: This request involves building multiple files (a scaffold). \
4840 Before implementing, draft a concise blueprint to `.hematite/PLAN.md` using `write_file`. \
4841 The blueprint should list:\n\
4842 1. The target directory path\n\
4843 2. Each file to create (with a one-line description of its purpose)\n\
4844 3. Key design decisions (e.g. color scheme, layout approach)\n\n\
4845 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for path accuracy.\n\
4846 After writing the PLAN.md, respond with a brief summary of what you planned. \
4847 Do NOT start implementing yet — just write the plan."
4848 .to_string(),
4849 );
4850 }
4851
4852 let mut implementation_started = false;
4853 let mut plan_drafted_this_turn = false;
4854 let mut non_mutating_plan_steps = 0usize;
4855 let non_mutating_plan_soft_cap = 5usize;
4856 let non_mutating_plan_hard_cap = 8usize;
4857 let mut overview_runtime_trace: Option<String> = None;
4858
4859 let max_iters = 25;
4861 let mut consecutive_errors = 0;
4862 let mut empty_cleaned_nudges = 0u8;
4863 let mut first_iter = true;
4864 let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
4865 let _result_counts: std::collections::HashMap<String, usize> =
4867 std::collections::HashMap::new();
4868 let mut repeat_counts: std::collections::HashMap<String, usize> =
4870 std::collections::HashMap::new();
4871 let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
4872 std::collections::HashMap::new();
4873 let mut successful_read_targets: std::collections::HashSet<String> =
4874 std::collections::HashSet::new();
4875 let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
4877 std::collections::HashSet::new();
4878 let mut successful_grep_targets: std::collections::HashSet<String> =
4879 std::collections::HashSet::new();
4880 let mut no_match_grep_targets: std::collections::HashSet<String> =
4881 std::collections::HashSet::new();
4882 let mut broad_grep_targets: std::collections::HashSet<String> =
4883 std::collections::HashSet::new();
4884 let mut sovereign_task_root: Option<String> = None;
4885 let mut sovereign_scaffold_targets: std::collections::BTreeSet<String> =
4886 std::collections::BTreeSet::new();
4887 let mut turn_mutated_paths: std::collections::BTreeSet<String> =
4888 std::collections::BTreeSet::new();
4889 let mut mutation_counts_by_path: std::collections::HashMap<String, usize> =
4890 std::collections::HashMap::new();
4891 let mut frontend_polish_intervention_emitted = false;
4892 let mut visible_closeout_emitted = false;
4893
4894 let mut turn_anchor = self.history.len().saturating_sub(1);
4896
4897 {
4901 let context_length = self.engine.current_context_length();
4902 let vram_ratio = self.gpu_state.ratio();
4903 if compaction::should_compact(&self.history, context_length, vram_ratio) {
4904 let _ = tx
4905 .send(InferenceEvent::Thought(
4906 "Pre-turn compaction: context pressure detected — compacting history before inference.".into(),
4907 ))
4908 .await;
4909 if self
4910 .compact_history_if_needed(&tx, Some(turn_anchor))
4911 .await?
4912 {
4913 turn_anchor = self
4916 .history
4917 .iter()
4918 .rposition(|m| m.role == "user")
4919 .unwrap_or(self.history.len().saturating_sub(1));
4920 }
4921 }
4922 }
4923
4924 let _sleep_guard = crate::ui::sleep_inhibitor::SleepInhibitor::acquire();
4927
4928 let (budget_input_start, budget_output_start) = {
4930 let econ = self
4931 .engine
4932 .economics
4933 .lock()
4934 .unwrap_or_else(|p| p.into_inner());
4935 (econ.input_tokens, econ.output_tokens)
4936 };
4937 let budget_history_est: usize = self
4939 .history
4940 .iter()
4941 .take(turn_anchor)
4942 .map(|m| crate::agent::inference::estimate_message_tokens(m))
4943 .sum();
4944 let mut budget_tool_costs: Vec<crate::agent::economics::ToolCost> = Vec::new();
4946
4947 for _iter in 0..max_iters {
4948 let context_prep_start = tokio::time::Instant::now();
4949 let mut mutation_occurred = false;
4950 if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
4952 self.cancel_token
4953 .store(false, std::sync::atomic::Ordering::SeqCst);
4954 let _ = tx
4955 .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
4956 .await;
4957 let _ = tx.send(InferenceEvent::Done).await;
4958 return Ok(());
4959 }
4960
4961 if self
4963 .compact_history_if_needed(&tx, Some(turn_anchor))
4964 .await?
4965 {
4966 turn_anchor = 2;
4969 }
4970
4971 let inject_vein = first_iter && !implement_current_plan;
4975 let messages = if implement_current_plan {
4976 first_iter = false;
4977 self.context_window_slice_from(turn_anchor)
4978 } else {
4979 first_iter = false;
4980 self.context_window_slice()
4981 };
4982
4983 let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
4987 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
4990 let mut msgs = vec![self.history[0].clone()];
4991 msgs.push(ChatMessage::system(&intervention));
4992 msgs
4993 } else {
4994 let merged =
4995 format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
4996 vec![ChatMessage::system(&merged)]
4997 }
4998 } else {
4999 vec![self.history[0].clone()]
5000 };
5001
5002 if inject_vein {
5006 if let Some(ctx) = vein_context.as_deref() {
5007 if crate::agent::inference::is_hematite_native_model(
5008 &self.engine.current_model(),
5009 ) {
5010 prompt_msgs.push(ChatMessage::system(ctx));
5011 } else {
5012 let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
5013 prompt_msgs[0] = ChatMessage::system(&merged);
5014 }
5015 }
5016 }
5017 if let Some(root) = sovereign_task_root.as_ref() {
5018 let sovereign_root_instruction = format!(
5019 "EFFECTIVE TASK ROOT: This sovereign scaffold turn is now rooted at:\n\
5020 `{root}`\n\n\
5021 Treat that directory as the active project root for the rest of this turn. \
5022 All reads, writes, verification, and summaries must stay scoped to that root. \
5023 Ignore unrelated repo context such as `./src` unless the user explicitly asks about it. \
5024 Keep building within this sovereign root instead of reasoning from the original workspace."
5025 );
5026 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
5027 prompt_msgs.push(ChatMessage::system(&sovereign_root_instruction));
5028 } else {
5029 let merged = format!(
5030 "{}\n\n{}",
5031 prompt_msgs[0].content.as_str(),
5032 sovereign_root_instruction
5033 );
5034 prompt_msgs[0] = ChatMessage::system(&merged);
5035 }
5036 }
5037 prompt_msgs.extend(messages);
5038 if let Some(budget_note) =
5039 enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
5040 {
5041 self.emit_operator_checkpoint(
5042 &tx,
5043 OperatorCheckpointState::BudgetReduced,
5044 budget_note,
5045 )
5046 .await;
5047 let recipe = plan_recovery(
5048 RecoveryScenario::PromptBudgetPressure,
5049 &self.recovery_context,
5050 );
5051 self.emit_recovery_recipe_summary(
5052 &tx,
5053 recipe.recipe.scenario.label(),
5054 compact_recovery_plan_summary(&recipe),
5055 )
5056 .await;
5057 }
5058 self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
5059 .await;
5060
5061 let turn_tools = if yolo
5062 || (explicit_search_request && grounded_research_results.is_some())
5063 {
5064 Vec::new()
5066 } else if intent.sovereign_mode {
5067 self.tools
5068 .iter()
5069 .filter(|t| {
5070 t.function.name != "shell" && t.function.name != "run_workspace_workflow"
5071 })
5072 .cloned()
5073 .collect::<Vec<_>>()
5074 } else {
5075 self.tools.clone()
5076 };
5077
5078 let context_prep_ms = context_prep_start.elapsed().as_millis();
5079 let inference_start = tokio::time::Instant::now();
5080
5081 let explicit_search_synthesis = explicit_search_request
5082 && grounded_research_results.is_some()
5083 && turn_tools.is_empty();
5084
5085 let call_result = if explicit_search_synthesis {
5086 match tokio::time::timeout(
5087 tokio::time::Duration::from_secs(20),
5088 self.engine
5089 .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref()),
5090 )
5091 .await
5092 {
5093 Ok(result) => result,
5094 Err(_) => Err(
5095 "explicit_search_synthesis_timeout: grounded research summary took too long to complete"
5096 .to_string(),
5097 ),
5098 }
5099 } else {
5100 self.engine
5101 .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
5102 .await
5103 };
5104
5105 let (mut text, mut tool_calls, usage, finish_reason) = match call_result {
5106 Ok(result) => result,
5107 Err(e) => {
5108 if explicit_search_synthesis
5109 && (e.contains("explicit_search_synthesis_timeout")
5110 || e.contains("provider_degraded")
5111 || e.contains("empty response"))
5112 {
5113 if let Some(results) = grounded_research_results.as_deref() {
5114 let response = build_research_provider_fallback(results);
5115 self.history.push(ChatMessage::assistant_text(&response));
5116 self.transcript.log_agent(&response);
5117 let _ = tx
5118 .send(InferenceEvent::Thought(
5119 "Search synthesis stalled; returning a grounded fallback summary from the fetched results."
5120 .into(),
5121 ))
5122 .await;
5123 for chunk in chunk_text(&response, 8) {
5124 let _ = tx.send(InferenceEvent::Token(chunk)).await;
5125 }
5126 let _ = tx.send(InferenceEvent::Done).await;
5127 return Ok(());
5128 }
5129 }
5130
5131 let class = classify_runtime_failure(&e);
5132 if should_retry_runtime_failure(class) {
5133 if self.recovery_context.consume_transient_retry() {
5134 let label = match class {
5135 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
5136 _ => "empty_model_response",
5137 };
5138 self.transcript.log_system(&format!(
5139 "Automatic provider recovery triggered: {}",
5140 e.trim()
5141 ));
5142 self.emit_recovery_recipe_summary(
5143 &tx,
5144 label,
5145 compact_runtime_recovery_summary(class),
5146 )
5147 .await;
5148 let _ = tx
5149 .send(InferenceEvent::ProviderStatus {
5150 state: ProviderRuntimeState::Recovering,
5151 summary: compact_runtime_recovery_summary(class).into(),
5152 })
5153 .await;
5154 self.emit_operator_checkpoint(
5155 &tx,
5156 OperatorCheckpointState::RecoveringProvider,
5157 compact_runtime_recovery_summary(class),
5158 )
5159 .await;
5160 continue;
5161 }
5162 }
5163
5164 if explicit_search_request
5165 && matches!(
5166 class,
5167 RuntimeFailureClass::ProviderDegraded
5168 | RuntimeFailureClass::EmptyModelResponse
5169 )
5170 {
5171 if let Some(results) = grounded_research_results.as_deref() {
5172 let response = build_research_provider_fallback(results);
5173 self.history.push(ChatMessage::assistant_text(&response));
5174 self.transcript.log_agent(&response);
5175 for chunk in chunk_text(&response, 8) {
5176 let _ = tx.send(InferenceEvent::Token(chunk)).await;
5177 }
5178 let _ = tx.send(InferenceEvent::Done).await;
5179 return Ok(());
5180 }
5181 }
5182
5183 self.emit_runtime_failure(&tx, class, &e).await;
5184 break;
5185 }
5186 };
5187 let inference_ms = inference_start.elapsed().as_millis();
5188 let execution_start = tokio::time::Instant::now();
5189 self.emit_provider_live(&tx).await;
5190
5191 if text.is_none() && tool_calls.is_none() {
5196 if let Some(reasoning) = usage.as_ref().and_then(|u| {
5197 if u.completion_tokens > 2000 {
5198 Some(u.completion_tokens)
5199 } else {
5200 None
5201 }
5202 }) {
5203 self.emit_operator_checkpoint(
5204 &tx,
5205 OperatorCheckpointState::BlockedToolLoop,
5206 format!(
5207 "Reasoning collapse detected ({} tokens of empty output).",
5208 reasoning
5209 ),
5210 )
5211 .await;
5212 break;
5213 }
5214 }
5215
5216 if let Some(ref u) = usage {
5218 let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
5219 }
5220
5221 if tool_calls
5224 .as_ref()
5225 .map(|calls| calls.is_empty())
5226 .unwrap_or(true)
5227 {
5228 if let Some(raw_text) = text.as_deref() {
5229 let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
5230 if !native_calls.is_empty() {
5231 tool_calls = Some(native_calls);
5232 let stripped =
5233 crate::agent::inference::strip_native_tool_call_text(raw_text);
5234 text = if stripped.trim().is_empty() {
5235 None
5236 } else {
5237 Some(stripped)
5238 };
5239 }
5240 }
5241 }
5242
5243 let tool_calls = tool_calls.filter(|c| !c.is_empty());
5246 let near_context_ceiling = usage
5247 .as_ref()
5248 .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
5249 .unwrap_or(false);
5250
5251 if let Some(calls) = tool_calls {
5252 let (calls, prune_trace_note) =
5253 prune_architecture_trace_batch(calls, architecture_overview_mode);
5254 if let Some(note) = prune_trace_note {
5255 let _ = tx.send(InferenceEvent::Thought(note)).await;
5256 }
5257
5258 let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
5259 calls,
5260 self.workflow_mode.is_read_only(),
5261 architecture_overview_mode,
5262 );
5263 if let Some(note) = prune_bloat_note {
5264 let _ = tx.send(InferenceEvent::Thought(note)).await;
5265 }
5266
5267 let (calls, prune_note) = prune_authoritative_tool_batch(
5268 calls,
5269 grounded_trace_mode,
5270 &effective_user_input,
5271 );
5272 if let Some(note) = prune_note {
5273 let _ = tx.send(InferenceEvent::Thought(note)).await;
5274 }
5275
5276 let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
5277 if let Some(note) = prune_redir_note {
5278 let _ = tx.send(InferenceEvent::Thought(note)).await;
5279 }
5280
5281 let (calls, batch_note) = order_batch_reads_first(calls);
5282 if let Some(note) = batch_note {
5283 let _ = tx.send(InferenceEvent::Thought(note)).await;
5284 }
5285
5286 if let Some(repeated_path) = calls
5287 .iter()
5288 .filter_map(|c| repeated_read_target(&c.function))
5289 .find(|path| successful_read_targets.contains(path))
5290 {
5291 let repeated_path = repeated_path.to_string();
5292
5293 let err_msg = format!(
5294 "Read discipline: You already read `{}` recently. Use `inspect_lines` on a specific window or `grep_files` to find content, then continue with your edit.",
5295 repeated_path
5296 );
5297 let _ = tx
5298 .clone()
5299 .send(InferenceEvent::Token(format!("\n⚠️ {}\n", err_msg)))
5300 .await;
5301 let _ = tx
5302 .clone()
5303 .send(InferenceEvent::Thought(format!(
5304 "Intervention: {}",
5305 err_msg
5306 )))
5307 .await;
5308
5309 for call in &calls {
5312 self.history.push(ChatMessage::tool_result_for_model(
5313 &call.id,
5314 &call.function.name,
5315 &err_msg,
5316 &self.engine.current_model(),
5317 ));
5318 }
5319 self.emit_done_events(&tx).await;
5320 return Ok(());
5321 }
5322
5323 if capability_mode
5324 && !capability_needs_repo
5325 && calls
5326 .iter()
5327 .all(|c| is_capability_probe_tool(&c.function.name))
5328 {
5329 loop_intervention = Some(
5330 "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
5331 Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
5332 Do not mention raw `mcp__*` names unless they are active and directly relevant."
5333 .to_string(),
5334 );
5335 let _ = tx.clone()
5336 .send(InferenceEvent::Thought(
5337 "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
5338 .into(),
5339 ))
5340 .await;
5341 continue;
5342 }
5343
5344 let raw_content = text.as_deref().unwrap_or(" ");
5347
5348 if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
5349 let _ = tx
5350 .clone()
5351 .send(InferenceEvent::Thought(thought.clone()))
5352 .await;
5353 self.reasoning_history = Some(thought);
5355 }
5356
5357 let stored_tool_call_content = if implement_current_plan {
5360 cap_output(raw_content, 1200)
5361 } else {
5362 raw_content.to_string()
5363 };
5364 self.history.push(ChatMessage::assistant_tool_calls(
5365 &stored_tool_call_content,
5366 calls.clone(),
5367 ));
5368
5369 let mut results = Vec::new();
5371 let gemma4_model =
5372 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
5373 let latest_user_prompt = self.latest_user_prompt();
5374 let mut seen_call_keys = std::collections::HashSet::new();
5375 let mut deduped_calls = Vec::new();
5376 for call in calls.clone() {
5377 let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
5378 &call.function.name,
5379 &call.function.arguments,
5380 gemma4_model,
5381 latest_user_prompt,
5382 );
5383
5384 if crate::agent::policy::is_destructive_tool(&normalized_name) {
5386 if let Some(path) = crate::agent::policy::tool_path_argument(
5387 &normalized_name,
5388 &normalized_args,
5389 ) {
5390 let tracker = self.diff_tracker.clone();
5391 tokio::spawn(async move {
5392 let mut guard = tracker.lock().await;
5393 let _ = guard.on_file_access(std::path::Path::new(&path));
5394 });
5395 }
5396 }
5397
5398 if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
5400 let cmd_val = normalized_args
5401 .get("command")
5402 .or_else(|| normalized_args.get("workflow"));
5403
5404 if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
5405 if cfg!(windows)
5406 && (cmd.contains("/dev/")
5407 || cmd.contains("/etc/")
5408 || cmd.contains("/var/"))
5409 {
5410 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.";
5411 let _ = tx
5412 .clone()
5413 .send(InferenceEvent::Token(format!("\n🚨 {}\n", err_msg)))
5414 .await;
5415 let _ = tx
5416 .clone()
5417 .send(InferenceEvent::Thought(format!(
5418 "Panic blocked: {}",
5419 err_msg
5420 )))
5421 .await;
5422
5423 let mut err_results = Vec::new();
5425 for c in &calls {
5426 err_results.push(ChatMessage::tool_result_for_model(
5427 &c.id,
5428 &c.function.name,
5429 err_msg,
5430 &self.engine.current_model(),
5431 ));
5432 }
5433 for res in err_results {
5434 self.history.push(res);
5435 }
5436 self.emit_done_events(&tx).await;
5437 return Ok(());
5438 }
5439
5440 if is_natural_language_hallucination(cmd) {
5441 let err_msg = format!(
5442 "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
5443 Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
5444 Use the correct surgical tool (like `create_directory`) instead of overthinking.",
5445 cmd
5446 );
5447 let _ = tx
5448 .send(InferenceEvent::Thought(format!(
5449 "Sanitizer error: {}",
5450 err_msg
5451 )))
5452 .await;
5453 results.push(ToolExecutionOutcome {
5454 call_id: call.id.clone(),
5455 tool_name: normalized_name.clone(),
5456 args: normalized_args.clone(),
5457 output: err_msg,
5458 is_error: true,
5459 blocked_by_policy: false,
5460 msg_results: Vec::new(),
5461 latest_target_dir: None,
5462 plan_drafted_this_turn: false,
5463 parsed_plan_handoff: None,
5464 });
5465 continue;
5466 }
5467 }
5468 }
5469
5470 let key = canonical_tool_call_key(&normalized_name, &normalized_args);
5471 if seen_call_keys.insert(key) {
5472 let repeat_guard_exempt = matches!(
5473 normalized_name.as_str(),
5474 "verify_build" | "git_commit" | "git_push"
5475 );
5476 if !repeat_guard_exempt {
5477 if let Some(cached) = completed_tool_cache
5478 .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
5479 {
5480 let _ = tx
5481 .send(InferenceEvent::Thought(
5482 "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
5483 .to_string(),
5484 ))
5485 .await;
5486 loop_intervention = Some(format!(
5487 "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.",
5488 cached.tool_name
5489 ));
5490 continue;
5491 }
5492 }
5493 deduped_calls.push(call);
5494 } else {
5495 let _ = tx
5496 .send(InferenceEvent::Thought(
5497 "Duplicate tool call skipped: identical built-in invocation already ran this turn."
5498 .to_string(),
5499 ))
5500 .await;
5501 }
5502 }
5503
5504 let total_used = usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
5507 let ctx_len = self.engine.current_context_length();
5508 let remaining = ctx_len.saturating_sub(total_used);
5509 let tool_budget = remaining.saturating_sub(3000);
5510 let budget_per_call = if deduped_calls.is_empty() {
5511 0
5512 } else {
5513 tool_budget / deduped_calls.len().max(1)
5514 };
5515
5516 let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
5518 .into_iter()
5519 .partition(|c| is_parallel_safe(&c.function.name));
5520
5521 if !parallel_calls.is_empty() {
5523 let mut tasks = Vec::new();
5524 for call in parallel_calls {
5525 let tx_clone = tx.clone();
5526 let config_clone = config.clone();
5527 let call_with_id = call.clone();
5529 tasks.push(self.process_tool_call(
5530 call_with_id.function,
5531 config_clone,
5532 yolo,
5533 tx_clone,
5534 call_with_id.id,
5535 budget_per_call,
5536 ));
5537 }
5538 results.extend(futures::future::join_all(tasks).await);
5540 }
5541
5542 let mut sovereign_bootstrap_complete = false;
5544
5545 for call in serial_calls {
5546 let outcome = self
5547 .process_tool_call(
5548 call.function,
5549 config.clone(),
5550 yolo,
5551 tx.clone(),
5552 call.id,
5553 budget_per_call,
5554 )
5555 .await;
5556
5557 if !outcome.is_error {
5558 let tool_name = outcome.tool_name.as_str();
5559 if matches!(
5560 tool_name,
5561 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5562 ) {
5563 if let Some(target) = action_target_path(tool_name, &outcome.args) {
5564 let normalized_path = normalize_workspace_path(&target);
5565 let rewrite_count = mutation_counts_by_path
5566 .entry(normalized_path.clone())
5567 .and_modify(|count| *count += 1)
5568 .or_insert(1);
5569
5570 let is_frontend_asset = [
5571 ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue",
5572 ".svelte",
5573 ]
5574 .iter()
5575 .any(|ext| normalized_path.ends_with(ext));
5576
5577 if is_frontend_asset && *rewrite_count >= 3 {
5578 frontend_polish_intervention_emitted = true;
5579 loop_intervention = Some(format!(
5580 "REWRITE LIMIT REACHED. You have updated `{}` {} times this turn. To prevent reasoning collapse, further rewrites to this file are blocked. \
5581 Please UPDATE `.hematite/TASK.md` to check off these completed steps, and response with a concise engineering summary of the implementation status.",
5582 normalized_path, rewrite_count
5583 ));
5584 results.push(outcome);
5585 let _ = tx.send(InferenceEvent::Thought("Frontend rewrite guard: block reached — prompting for task update and summary.".to_string())).await;
5586 break; } else if !frontend_polish_intervention_emitted
5588 && is_frontend_asset
5589 && *rewrite_count >= 2
5590 {
5591 frontend_polish_intervention_emitted = true;
5592 loop_intervention = Some(format!(
5593 "STOP REWRITING. You have already written `{}` {} times. The current version is sufficient as a foundation. \
5594 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.",
5595 normalized_path, rewrite_count
5596 ));
5597 results.push(outcome);
5598 let _ = tx.send(InferenceEvent::Thought("Frontend polish guard: repeated rewrite detected; prompting for progress log and next steps.".to_string())).await;
5599 break; }
5601 }
5602 }
5603 }
5604
5605 if !outcome.is_error
5606 && intent.sovereign_mode
5607 && is_scaffold_request(&effective_user_input)
5608 && outcome.latest_target_dir.is_some()
5609 {
5610 sovereign_bootstrap_complete = true;
5611 }
5612 results.push(outcome);
5613 if sovereign_bootstrap_complete {
5614 let _ = tx
5615 .send(InferenceEvent::Thought(
5616 "Sovereign scaffold bootstrap complete: stopping this session after root setup so the resumed session can continue inside the new project."
5617 .to_string(),
5618 ))
5619 .await;
5620 break;
5621 }
5622 }
5623
5624 let execution_ms = execution_start.elapsed().as_millis();
5625 let _ = tx
5626 .send(InferenceEvent::TurnTiming {
5627 context_prep_ms: context_prep_ms as u128,
5628 inference_ms: inference_ms as u128,
5629 execution_ms: execution_ms as u128,
5630 })
5631 .await;
5632
5633 let mut authoritative_tool_output: Option<String> = None;
5635 let mut blocked_policy_output: Option<String> = None;
5636 let mut recoverable_policy_intervention: Option<String> = None;
5637 let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
5638 let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
5639 None;
5640 for res in results {
5641 let call_id = res.call_id.clone();
5642 let tool_name = res.tool_name.clone();
5643 let final_output = res.output.clone();
5644 let is_error = res.is_error;
5645 for msg in res.msg_results {
5646 self.history.push(msg);
5647 }
5648
5649 if let Some(path) = res.latest_target_dir {
5651 if intent.sovereign_mode && sovereign_task_root.is_none() {
5652 sovereign_task_root = Some(path.clone());
5653 self.pending_teleport_handoff = Some(SovereignTeleportHandoff {
5654 root: path.clone(),
5655 plan: build_sovereign_scaffold_handoff(
5656 &effective_user_input,
5657 &sovereign_scaffold_targets,
5658 ),
5659 });
5660 let _ = tx
5661 .send(InferenceEvent::Thought(format!(
5662 "Sovereign scaffold root established at `{}`; rebinding project context there for the rest of this turn.",
5663 path
5664 )))
5665 .await;
5666 }
5667 self.latest_target_dir = Some(path);
5668 }
5669
5670 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
5671 if let Some(root) = sovereign_task_root.as_ref() {
5672 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
5673 let resolved = crate::tools::file_ops::resolve_candidate(path);
5674 let root_path = std::path::Path::new(root);
5675 if let Ok(relative) = resolved.strip_prefix(root_path) {
5676 if !relative.as_os_str().is_empty() {
5677 sovereign_scaffold_targets
5678 .insert(relative.to_string_lossy().replace('\\', "/"));
5679 }
5680 self.pending_teleport_handoff =
5681 Some(SovereignTeleportHandoff {
5682 root: root.clone(),
5683 plan: build_sovereign_scaffold_handoff(
5684 &effective_user_input,
5685 &sovereign_scaffold_targets,
5686 ),
5687 });
5688 }
5689 }
5690 }
5691 }
5692 if matches!(
5693 tool_name.as_str(),
5694 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5695 ) {
5696 mutation_occurred = true;
5697 implementation_started = true;
5698 if !is_error {
5699 if let Some(target) = action_target_path(&tool_name, &res.args) {
5700 turn_mutated_paths.insert(target);
5701 }
5702 }
5703 if !is_error {
5705 let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
5706 if !path.is_empty() {
5707 self.vein.bump_heat(path);
5708 self.l1_context = self.vein.l1_context();
5709 compact_stale_reads(&mut self.history, path);
5712 }
5713 self.refresh_repo_map();
5715 }
5716 }
5717
5718 if !is_error
5719 && matches!(
5720 tool_name.as_str(),
5721 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5722 )
5723 {
5724 }
5726
5727 if res.plan_drafted_this_turn {
5728 plan_drafted_this_turn = true;
5729 }
5730 if let Some(plan) = res.parsed_plan_handoff.clone() {
5731 self.session_memory.current_plan = Some(plan);
5732 }
5733
5734 if tool_name == "verify_build" {
5735 self.record_session_verification(
5736 !is_error
5737 && (final_output.contains("BUILD OK")
5738 || final_output.contains("BUILD SUCCESS")
5739 || final_output.contains("BUILD OKAY")),
5740 if is_error {
5741 "Explicit verify_build failed."
5742 } else {
5743 "Explicit verify_build passed."
5744 },
5745 );
5746 }
5747
5748 let call_key = format!(
5750 "{}:{}",
5751 tool_name,
5752 serde_json::to_string(&res.args).unwrap_or_default()
5753 );
5754 let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
5755 *repeat_count += 1;
5756
5757 let repeat_guard_exempt =
5760 is_repeat_guard_exempt_tool_call(&tool_name, &res.args);
5761 if *repeat_count >= 2 && !repeat_guard_exempt {
5762 loop_intervention = Some(format!(
5763 "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
5764 Do not call it again. Either answer directly from what you already know, \
5765 use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
5766 or ask the user for clarification.",
5767 tool_name, *repeat_count
5768 ));
5769 let _ = tx
5770 .send(InferenceEvent::Thought(format!(
5771 "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
5772 tool_name, *repeat_count
5773 )))
5774 .await;
5775 }
5776
5777 if *repeat_count >= 3 && !repeat_guard_exempt {
5778 self.emit_runtime_failure(
5779 &tx,
5780 RuntimeFailureClass::ToolLoop,
5781 &format!(
5782 "STRICT: You are stuck in a reasoning loop calling `{}`. \
5783 STOP repeating this call. Switch to grounded filesystem tools \
5784 (like `read_file`, `inspect_lines`, or `edit_file`) instead of \
5785 attempting this workflow again.",
5786 tool_name
5787 ),
5788 )
5789 .await;
5790 return Ok(());
5791 }
5792
5793 if is_error {
5794 consecutive_errors += 1;
5795 } else {
5796 consecutive_errors = 0;
5797 }
5798
5799 if consecutive_errors >= 3 {
5800 loop_intervention = Some(
5801 "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
5802 STOP all tool calls immediately. Analyze why your previous 3 calls failed \
5803 (check for hallucinations or invalid arguments) and ask the user for \
5804 clarification if you cannot proceed.".to_string()
5805 );
5806 }
5807
5808 if consecutive_errors >= 4 {
5809 self.emit_runtime_failure(
5810 &tx,
5811 RuntimeFailureClass::ToolLoop,
5812 "Hard termination: too many consecutive tool errors.",
5813 )
5814 .await;
5815 return Ok(());
5816 }
5817
5818 if !should_suppress_recoverable_tool_result(
5819 res.blocked_by_policy,
5820 recoverable_policy_intervention.is_some(),
5821 ) {
5822 let _ = tx
5823 .send(InferenceEvent::ToolCallResult {
5824 id: call_id.clone(),
5825 name: tool_name.clone(),
5826 result: final_output.clone(),
5827 is_error,
5828 })
5829 .await;
5830 }
5831
5832 let repeat_guard_exempt = matches!(
5833 tool_name.as_str(),
5834 "verify_build" | "git_commit" | "git_push"
5835 );
5836 if !repeat_guard_exempt {
5837 completed_tool_cache.insert(
5838 canonical_tool_call_key(&tool_name, &res.args),
5839 CachedToolResult {
5840 tool_name: tool_name.clone(),
5841 },
5842 );
5843 }
5844
5845 let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
5847 self.engine.current_context_length(),
5848 );
5849 let capped = if implement_current_plan {
5850 cap_output(&final_output, 1200)
5851 } else if compact_ctx
5852 && (tool_name == "read_file" || tool_name == "inspect_lines")
5853 {
5854 let limit = 3000usize;
5856 if final_output.len() > limit {
5857 let total_lines = final_output.lines().count();
5858 let mut split_at = limit;
5859 while !final_output.is_char_boundary(split_at) && split_at > 0 {
5860 split_at -= 1;
5861 }
5862 let scratch = write_output_to_scratch(&final_output, &tool_name)
5863 .map(|p| format!(" Full file also saved to '{p}'."))
5864 .unwrap_or_default();
5865 format!(
5866 "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
5867 &final_output[..split_at],
5868 total_lines,
5869 total_lines.saturating_sub(150),
5870 scratch,
5871 )
5872 } else {
5873 final_output.clone()
5874 }
5875 } else {
5876 cap_output_for_tool(&final_output, 8000, &tool_name)
5877 };
5878 self.history.push(ChatMessage::tool_result_for_model(
5879 &call_id,
5880 &tool_name,
5881 &capped,
5882 &self.engine.current_model(),
5883 ));
5884 budget_tool_costs.push(crate::agent::economics::ToolCost {
5885 name: tool_name.clone(),
5886 tokens: capped.len() / 4,
5887 });
5888
5889 if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
5890 {
5891 overview_runtime_trace =
5892 Some(summarize_runtime_trace_output(&final_output));
5893 }
5894
5895 if !architecture_overview_mode
5896 && !is_error
5897 && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
5898 || (toolchain_mode && tool_name == "describe_toolchain"))
5899 {
5900 authoritative_tool_output = Some(final_output.clone());
5901 }
5902
5903 if !is_error && tool_name == "read_file" {
5904 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
5905 let normalized = normalize_workspace_path(path);
5906 let read_offset =
5907 res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
5908 successful_read_targets.insert(normalized.clone());
5909 successful_read_regions.insert((normalized.clone(), read_offset));
5910 }
5911 }
5912
5913 if !is_error && tool_name == "grep_files" {
5914 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
5915 let normalized = normalize_workspace_path(path);
5916 if final_output.starts_with("No matches for ") {
5917 no_match_grep_targets.insert(normalized);
5918 } else if grep_output_is_high_fanout(&final_output) {
5919 broad_grep_targets.insert(normalized);
5920 } else {
5921 successful_grep_targets.insert(normalized);
5922 }
5923 }
5924 }
5925
5926 if is_error
5927 && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
5928 && (final_output.contains("search string not found")
5929 || final_output.contains("search string is too short")
5930 || final_output.contains("search string matched"))
5931 {
5932 if let Some(target) = action_target_path(&tool_name, &res.args) {
5933 let guidance = if final_output.contains("matched") {
5934 let snippet = read_file_preview_for_retry(&target, 120);
5937 format!(
5938 "EDIT FAILED — search string matched multiple locations in `{target}`. \
5939 You need a longer, more unique search string that includes surrounding context.\n\
5940 Current file content (first 120 lines):\n```\n{snippet}\n```\n\
5941 Retry `{tool_name}` with a search string that is unique in the file."
5942 )
5943 } else {
5944 let snippet = read_file_preview_for_retry(&target, 200);
5947 let normalized = normalize_workspace_path(&target);
5950 {
5951 let mut ag = self.action_grounding.lock().await;
5952 let turn = ag.turn_index;
5953 ag.observed_paths.insert(normalized.clone(), turn);
5954 ag.inspected_paths.insert(normalized, turn);
5955 }
5956 format!(
5957 "EDIT FAILED — search string did not match any text in `{target}`.\n\
5958 The model must have generated text that differs from what is actually in the file \
5959 (wrong whitespace, indentation, or stale content).\n\
5960 Current file content (up to 200 lines shown):\n```\n{snippet}\n```\n\
5961 Find the exact line(s) to change above, copy the text character-for-character \
5962 (preserving indentation), and immediately retry `{tool_name}` \
5963 with that exact text as the search string. Do NOT call read_file again — \
5964 the content is already shown above."
5965 )
5966 };
5967 loop_intervention = Some(guidance);
5968 *repeat_count = 0;
5969 }
5970 }
5971
5972 if is_error
5975 && tool_name == "shell"
5976 && final_output.contains("Use the run_code tool instead")
5977 && loop_intervention.is_none()
5978 {
5979 loop_intervention = Some(
5980 "STOP. Shell was blocked because this is a computation task. \
5981 You MUST use `run_code` now — write the code and run it. \
5982 Do NOT output an error message or give up. \
5983 Call `run_code` with the appropriate language and code to compute the answer. \
5984 If writing Python, pass `language: \"python\"`. \
5985 If writing JavaScript, omit language or pass `language: \"javascript\"`."
5986 .to_string(),
5987 );
5988 }
5989
5990 if is_error
5993 && tool_name == "run_code"
5994 && (final_output.contains("source code could not be parsed")
5995 || final_output.contains("Expected ';'")
5996 || final_output.contains("Expected '}'")
5997 || final_output.contains("is not defined")
5998 && final_output.contains("deno"))
5999 && loop_intervention.is_none()
6000 {
6001 loop_intervention = Some(
6002 "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
6003 code but forgot to pass `language: \"python\"`. \
6004 Retry run_code with `language: \"python\"` and the same code. \
6005 Do NOT fall back to shell. Do NOT give up."
6006 .to_string(),
6007 );
6008 }
6009
6010 if res.blocked_by_policy
6011 && is_mcp_workspace_read_tool(&tool_name)
6012 && recoverable_policy_intervention.is_none()
6013 {
6014 recoverable_policy_intervention = Some(
6015 "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
6016 );
6017 recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
6018 recoverable_policy_checkpoint = Some((
6019 OperatorCheckpointState::BlockedPolicy,
6020 "MCP workspace read blocked; rerouting to built-in file tools."
6021 .to_string(),
6022 ));
6023 } else if res.blocked_by_policy
6024 && implement_current_plan
6025 && is_current_plan_irrelevant_tool(&tool_name)
6026 && recoverable_policy_intervention.is_none()
6027 {
6028 recoverable_policy_intervention = Some(format!(
6029 "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
6030 tool_name
6031 ));
6032 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6033 recoverable_policy_checkpoint = Some((
6034 OperatorCheckpointState::BlockedPolicy,
6035 format!(
6036 "Current-plan execution blocked unrelated tool `{}`.",
6037 tool_name
6038 ),
6039 ));
6040 } else if res.blocked_by_policy
6041 && implement_current_plan
6042 && final_output
6043 .contains("current-plan execution is locked to the saved target files")
6044 && recoverable_policy_intervention.is_none()
6045 {
6046 let target_files = self
6047 .session_memory
6048 .current_plan
6049 .as_ref()
6050 .map(|plan| plan.target_files.clone())
6051 .unwrap_or_default();
6052 recoverable_policy_intervention =
6053 Some(build_current_plan_scope_recovery_prompt(&target_files));
6054 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6055 recoverable_policy_checkpoint = Some((
6056 OperatorCheckpointState::BlockedPolicy,
6057 format!(
6058 "Current-plan execution blocked off-target path access via `{}`.",
6059 tool_name
6060 ),
6061 ));
6062 } else if res.blocked_by_policy
6063 && implement_current_plan
6064 && final_output.contains("requires recent file evidence")
6065 && recoverable_policy_intervention.is_none()
6066 {
6067 let target = action_target_path(&tool_name, &res.args)
6068 .unwrap_or_else(|| "the target file".to_string());
6069 recoverable_policy_intervention = Some(format!(
6070 "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
6071 ));
6072 recoverable_policy_recipe =
6073 Some(RecoveryScenario::RecentFileEvidenceMissing);
6074 recoverable_policy_checkpoint = Some((
6075 OperatorCheckpointState::BlockedRecentFileEvidence,
6076 format!("Edit blocked on `{target}`; recent file evidence missing."),
6077 ));
6078 } else if res.blocked_by_policy
6079 && implement_current_plan
6080 && final_output.contains("requires an exact local line window first")
6081 && recoverable_policy_intervention.is_none()
6082 {
6083 let target = action_target_path(&tool_name, &res.args)
6084 .unwrap_or_else(|| "the target file".to_string());
6085 recoverable_policy_intervention = Some(format!(
6086 "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
6087 ));
6088 recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
6089 recoverable_policy_checkpoint = Some((
6090 OperatorCheckpointState::BlockedExactLineWindow,
6091 format!("Edit blocked on `{target}`; exact line window required."),
6092 ));
6093 } else if res.blocked_by_policy
6094 && (final_output.contains("Prefer `")
6095 || final_output.contains("Prefer tool"))
6096 && recoverable_policy_intervention.is_none()
6097 {
6098 recoverable_policy_intervention = Some(final_output.clone());
6099 recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
6100 recoverable_policy_checkpoint = Some((
6101 OperatorCheckpointState::BlockedPolicy,
6102 "Action blocked by policy; self-correction triggered using tool recommendation."
6103 .to_string(),
6104 ));
6105 } else if res.blocked_by_policy && blocked_policy_output.is_none() {
6106 blocked_policy_output = Some(final_output.clone());
6107 }
6108
6109 if *repeat_count >= 5 {
6110 let _ = tx.send(InferenceEvent::Done).await;
6111 return Ok(());
6112 }
6113
6114 if implement_current_plan
6115 && !implementation_started
6116 && !is_error
6117 && is_non_mutating_plan_step_tool(&tool_name)
6118 {
6119 non_mutating_plan_steps += 1;
6120 }
6121 }
6122
6123 if sovereign_bootstrap_complete
6124 && intent.sovereign_mode
6125 && is_scaffold_request(&effective_user_input)
6126 {
6127 let response = if let Some(root) = sovereign_task_root.as_deref() {
6128 format!(
6129 "Project root created at `{root}`. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6130 )
6131 } else {
6132 "Project root created. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6133 .to_string()
6134 };
6135 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
6136 .await;
6137 return Ok(());
6138 }
6139
6140 if let Some(intervention) = recoverable_policy_intervention {
6141 if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
6142 self.emit_operator_checkpoint(&tx, state, summary).await;
6143 }
6144 if let Some(scenario) = recoverable_policy_recipe.take() {
6145 let recipe = plan_recovery(scenario, &self.recovery_context);
6146 self.emit_recovery_recipe_summary(
6147 &tx,
6148 recipe.recipe.scenario.label(),
6149 compact_recovery_plan_summary(&recipe),
6150 )
6151 .await;
6152 }
6153 loop_intervention = Some(intervention);
6154 let _ = tx
6155 .send(InferenceEvent::Thought(
6156 "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
6157 .into(),
6158 ))
6159 .await;
6160 continue;
6161 }
6162
6163 if architecture_overview_mode {
6164 match overview_runtime_trace.as_deref() {
6165 Some(runtime_trace) => {
6166 let response = build_architecture_overview_answer(runtime_trace);
6167 self.history.push(ChatMessage::assistant_text(&response));
6168 self.transcript.log_agent(&response);
6169
6170 for chunk in chunk_text(&response, 8) {
6171 if !chunk.is_empty() {
6172 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6173 }
6174 }
6175
6176 let _ = tx.send(InferenceEvent::Done).await;
6177 break;
6178 }
6179 None => {
6180 loop_intervention = Some(
6181 "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."
6182 .to_string(),
6183 );
6184 continue;
6185 }
6186 }
6187 }
6188
6189 if implement_current_plan
6190 && !implementation_started
6191 && non_mutating_plan_steps >= non_mutating_plan_hard_cap
6192 {
6193 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();
6194 self.history.push(ChatMessage::assistant_text(&msg));
6195 self.transcript.log_agent(&msg);
6196
6197 for chunk in chunk_text(&msg, 8) {
6198 if !chunk.is_empty() {
6199 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6200 }
6201 }
6202
6203 let _ = tx.send(InferenceEvent::Done).await;
6204 break;
6205 }
6206
6207 if let Some(blocked_output) = blocked_policy_output {
6208 self.emit_operator_checkpoint(
6209 &tx,
6210 OperatorCheckpointState::BlockedPolicy,
6211 "A blocked tool path was surfaced directly to the operator.",
6212 )
6213 .await;
6214 self.history
6215 .push(ChatMessage::assistant_text(&blocked_output));
6216 self.transcript.log_agent(&blocked_output);
6217
6218 for chunk in chunk_text(&blocked_output, 8) {
6219 if !chunk.is_empty() {
6220 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6221 }
6222 }
6223
6224 let _ = tx.send(InferenceEvent::Done).await;
6225 break;
6226 }
6227
6228 if let Some(tool_output) = authoritative_tool_output {
6229 self.history.push(ChatMessage::assistant_text(&tool_output));
6230 self.transcript.log_agent(&tool_output);
6231
6232 for chunk in chunk_text(&tool_output, 8) {
6233 if !chunk.is_empty() {
6234 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6235 }
6236 }
6237
6238 let _ = tx.send(InferenceEvent::Done).await;
6239 break;
6240 }
6241
6242 if implement_current_plan && !implementation_started {
6243 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.";
6244 if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
6245 loop_intervention = Some(format!(
6246 "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
6247 base
6248 ));
6249 } else {
6250 loop_intervention = Some(base.to_string());
6251 }
6252 } else if self.workflow_mode == WorkflowMode::Architect {
6253 loop_intervention = Some(
6254 format!(
6255 "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.",
6256 architect_handoff_contract()
6257 ),
6258 );
6259 }
6260
6261 if mutation_occurred && !yolo && !intent.sovereign_mode {
6263 let _ = tx
6264 .send(InferenceEvent::Thought(
6265 "Self-Verification: Running contract-aware workspace verification..."
6266 .into(),
6267 ))
6268 .await;
6269 let verify_outcome = self.auto_verify_workspace(&turn_mutated_paths).await;
6270 let verify_res = verify_outcome.summary;
6271 let verify_ok = verify_outcome.ok;
6272 self.record_verify_build_result(verify_ok, &verify_res)
6273 .await;
6274 self.record_session_verification(
6275 verify_ok,
6276 if verify_ok {
6277 "Automatic workspace verification passed."
6278 } else {
6279 "Automatic workspace verification failed."
6280 },
6281 );
6282 self.history.push(ChatMessage::system(&format!(
6283 "\n# SYSTEM VERIFICATION\n{verify_res}"
6284 )));
6285 let _ = tx
6286 .send(InferenceEvent::Thought(
6287 "Verification turn injected into history.".into(),
6288 ))
6289 .await;
6290 }
6291
6292 continue;
6294 } else if let Some(response_text) = text {
6295 if finish_reason.as_deref() == Some("length") && near_context_ceiling {
6296 if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
6297 let cleaned = build_session_reset_semantics_answer();
6298 self.history.push(ChatMessage::assistant_text(&cleaned));
6299 self.transcript.log_agent(&cleaned);
6300 for chunk in chunk_text(&cleaned, 8) {
6301 if !chunk.is_empty() {
6302 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6303 }
6304 }
6305 let _ = tx.send(InferenceEvent::Done).await;
6306 break;
6307 }
6308
6309 let warning = format_runtime_failure(
6310 RuntimeFailureClass::ContextWindow,
6311 "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.",
6312 );
6313 self.history.push(ChatMessage::assistant_text(&warning));
6314 self.transcript.log_agent(&warning);
6315 let _ = tx
6316 .send(InferenceEvent::Thought(
6317 "Length recovery: model hit the context ceiling before completing the answer."
6318 .into(),
6319 ))
6320 .await;
6321 for chunk in chunk_text(&warning, 8) {
6322 if !chunk.is_empty() {
6323 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6324 }
6325 }
6326 let _ = tx.send(InferenceEvent::Done).await;
6327 break;
6328 }
6329
6330 if response_text.contains("<|tool_call")
6331 || response_text.contains("[END_TOOL_REQUEST]")
6332 || response_text.contains("<|tool_response")
6333 || response_text.contains("<tool_response|>")
6334 {
6335 loop_intervention = Some(
6336 "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(),
6337 );
6338 continue;
6339 }
6340
6341 if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
6343 {
6344 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
6345 self.reasoning_history = Some(thought);
6348 }
6349
6350 let execution_ms = execution_start.elapsed().as_millis();
6351 let _ = tx
6352 .send(InferenceEvent::TurnTiming {
6353 context_prep_ms: context_prep_ms as u128,
6354 inference_ms: inference_ms as u128,
6355 execution_ms: execution_ms as u128,
6356 })
6357 .await;
6358
6359 let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
6361
6362 if implement_current_plan && !implementation_started {
6363 loop_intervention = Some(
6364 "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(),
6365 );
6366 continue;
6367 }
6368
6369 if cleaned.is_empty() {
6375 empty_cleaned_nudges += 1;
6376 if empty_cleaned_nudges == 1 {
6377 loop_intervention = Some(
6378 "Your visible response was empty. The tool already returned data. \
6379 Write your answer now in plain text — no <think> tags, no tool calls. \
6380 State the key facts in 2-5 sentences and stop."
6381 .to_string(),
6382 );
6383 continue;
6384 } else if empty_cleaned_nudges == 2 {
6385 loop_intervention = Some(
6386 "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
6387 Write the answer in plain text right now. \
6388 Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
6389 .to_string(),
6390 );
6391 continue;
6392 }
6393 let class = RuntimeFailureClass::EmptyModelResponse;
6396 if let Some(summary) = maybe_deterministic_sovereign_closeout(
6397 self.session_memory.current_plan.as_ref(),
6398 mutation_occurred,
6399 ) {
6400 self.history.push(ChatMessage::assistant_text(&summary));
6401 self.transcript.log_agent(&summary);
6402 for chunk in chunk_text(&summary, 8) {
6403 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6404 }
6405 let _ = tx.send(InferenceEvent::Done).await;
6406 return Ok(());
6407 }
6408 self.emit_runtime_failure(
6409 &tx,
6410 class,
6411 "Model returned empty content after 2 nudge attempts.",
6412 )
6413 .await;
6414 break;
6415 }
6416
6417 let architect_handoff = self.persist_architect_handoff(&cleaned);
6418 self.history.push(ChatMessage::assistant_text(&cleaned));
6419 self.transcript.log_agent(&cleaned);
6420 visible_closeout_emitted = true;
6421
6422 for chunk in chunk_text(&cleaned, 8) {
6424 if !chunk.is_empty() {
6425 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6426 }
6427 }
6428
6429 if let Some(plan) = architect_handoff.as_ref() {
6430 let note = architect_handoff_operator_note(plan);
6431 self.history.push(ChatMessage::system(¬e));
6432 self.transcript.log_system(¬e);
6433 let _ = tx
6434 .send(InferenceEvent::MutedToken(format!("\n{}", note)))
6435 .await;
6436 }
6437
6438 self.emit_done_events(&tx).await;
6439 break;
6440 } else {
6441 let detail = "Model returned an empty response.";
6442 let class = classify_runtime_failure(detail);
6443 if should_retry_runtime_failure(class) {
6444 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6445 if let RecoveryDecision::Attempt(plan) =
6446 attempt_recovery(scenario, &mut self.recovery_context)
6447 {
6448 self.transcript.log_system(
6449 "Automatic provider recovery triggered: model returned an empty response.",
6450 );
6451 self.emit_recovery_recipe_summary(
6452 &tx,
6453 plan.recipe.scenario.label(),
6454 compact_recovery_plan_summary(&plan),
6455 )
6456 .await;
6457 let _ = tx
6458 .send(InferenceEvent::ProviderStatus {
6459 state: ProviderRuntimeState::Recovering,
6460 summary: compact_runtime_recovery_summary(class).into(),
6461 })
6462 .await;
6463 self.emit_operator_checkpoint(
6464 &tx,
6465 OperatorCheckpointState::RecoveringProvider,
6466 compact_runtime_recovery_summary(class),
6467 )
6468 .await;
6469 continue;
6470 }
6471 }
6472 }
6473
6474 if explicit_search_request
6475 && matches!(
6476 class,
6477 RuntimeFailureClass::ProviderDegraded
6478 | RuntimeFailureClass::EmptyModelResponse
6479 )
6480 {
6481 if let Some(results) = grounded_research_results.as_deref() {
6482 let response = build_research_provider_fallback(results);
6483 self.history.push(ChatMessage::assistant_text(&response));
6484 self.transcript.log_agent(&response);
6485 for chunk in chunk_text(&response, 8) {
6486 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6487 }
6488 let _ = tx.send(InferenceEvent::Done).await;
6489 return Ok(());
6490 }
6491 }
6492
6493 if implement_current_plan
6494 && mutation_occurred
6495 && matches!(class, RuntimeFailureClass::EmptyModelResponse)
6496 {
6497 if let Some(summary) = maybe_deterministic_sovereign_closeout(
6498 self.session_memory.current_plan.as_ref(),
6499 mutation_occurred,
6500 ) {
6501 self.history.push(ChatMessage::assistant_text(&summary));
6502 self.transcript.log_agent(&summary);
6503 for chunk in chunk_text(&summary, 8) {
6504 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6505 }
6506 let _ = tx.send(InferenceEvent::Done).await;
6507 return Ok(());
6508 }
6509 }
6510
6511 self.emit_runtime_failure(&tx, class, detail).await;
6512 break;
6513 }
6514 }
6515
6516 let task_progress_after = if implement_current_plan {
6517 read_task_checklist_progress()
6518 } else {
6519 None
6520 };
6521
6522 if implement_current_plan
6523 && !visible_closeout_emitted
6524 && should_continue_plan_execution(
6525 current_plan_pass,
6526 task_progress_before,
6527 task_progress_after,
6528 &turn_mutated_paths,
6529 )
6530 {
6531 if let Some(progress) = task_progress_after {
6532 let _ = tx
6533 .send(InferenceEvent::Thought(format!(
6534 "Checklist still has {} unchecked item(s). Continuing autonomous implementation pass {}.",
6535 progress.remaining,
6536 current_plan_pass + 1
6537 )))
6538 .await;
6539 let synthetic_turn = UserTurn {
6540 text: build_continue_plan_execution_prompt(progress),
6541 attached_document: None,
6542 attached_image: None,
6543 };
6544 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6545 }
6546 }
6547
6548 if implement_current_plan
6549 && !visible_closeout_emitted
6550 && turn_mutated_paths.is_empty()
6551 && current_plan_pass == 1
6552 {
6553 if let Some(progress) = task_progress_after.filter(|progress| progress.has_open_items())
6554 {
6555 let target_files = self
6556 .session_memory
6557 .current_plan
6558 .as_ref()
6559 .map(|plan| plan.target_files.clone())
6560 .unwrap_or_default();
6561 let _ = tx
6562 .send(InferenceEvent::Thought(
6563 "No target files were mutated during the first current-plan pass. Forcing one grounded implementation retry before allowing summary mode."
6564 .to_string(),
6565 ))
6566 .await;
6567 let synthetic_turn = UserTurn {
6568 text: build_force_plan_mutation_prompt(progress, &target_files),
6569 attached_document: None,
6570 attached_image: None,
6571 };
6572 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6573 }
6574 }
6575
6576 if implement_current_plan
6577 && !visible_closeout_emitted
6578 && !turn_mutated_paths.is_empty()
6579 && current_plan_pass <= 2
6580 {
6581 if let (Some(before), Some(after)) = (task_progress_before, task_progress_after) {
6582 if after.has_open_items()
6583 && after.remaining == before.remaining
6584 && after.completed == before.completed
6585 {
6586 let target_files = self
6587 .session_memory
6588 .current_plan
6589 .as_ref()
6590 .map(|plan| plan.target_files.clone())
6591 .unwrap_or_default();
6592 let _ = tx
6593 .send(InferenceEvent::Thought(
6594 "Implementation mutated target files, but the task ledger did not advance. Forcing one closeout pass to update `.hematite/TASK.md` before summary mode."
6595 .to_string(),
6596 ))
6597 .await;
6598 let synthetic_turn = UserTurn {
6599 text: build_task_ledger_closeout_prompt(after, &target_files),
6600 attached_document: None,
6601 attached_image: None,
6602 };
6603 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6604 }
6605 }
6606 }
6607
6608 if implement_current_plan && !visible_closeout_emitted {
6609 let _ = tx.send(InferenceEvent::Thought("Implementation passthrough complete. Requesting final engineering summary (NLG-only mode)...".to_string())).await;
6611
6612 let outstanding_note = task_progress_after
6613 .filter(|progress| progress.has_open_items())
6614 .map(|progress| {
6615 format!(
6616 " `.hematite/TASK.md` still has {} unchecked item(s); explain the concrete blocker or remaining non-optional work.",
6617 progress.remaining
6618 )
6619 })
6620 .unwrap_or_default();
6621 let synthetic_turn = UserTurn {
6622 text: format!(
6623 "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.{}",
6624 outstanding_note
6625 ),
6626 attached_document: None,
6627 attached_image: None,
6628 };
6629 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), true)).await;
6632 }
6633
6634 if plan_drafted_this_turn
6635 && matches!(
6636 self.workflow_mode,
6637 WorkflowMode::Auto | WorkflowMode::Architect
6638 )
6639 {
6640 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
6641 let _ = tx
6642 .send(InferenceEvent::ApprovalRequired {
6643 id: "plan_approval".to_string(),
6644 name: "plan_authorization".to_string(),
6645 display: "A comprehensive scaffolding blueprint has been drafted to .hematite/PLAN.md. Autonomously execute implementation now?".to_string(),
6646 diff: None,
6647 mutation_label: Some("SYSTEM PLAN AUTHORIZATION".to_string()),
6648 responder: appr_tx,
6649 })
6650 .await;
6651
6652 if let Ok(true) = appr_rx.await {
6653 self.history.clear();
6657 self.running_summary = None;
6658 self.set_workflow_mode(WorkflowMode::Code);
6659
6660 let _ = tx.send(InferenceEvent::ChainImplementPlan).await;
6661
6662 let next_input = implement_current_plan_prompt().to_string();
6663 let synthetic_turn = UserTurn {
6664 text: next_input,
6665 attached_document: None,
6666 attached_image: None,
6667 };
6668 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6669 }
6670 }
6671
6672 self.trim_history(80);
6673 self.refresh_session_memory();
6674 self.last_goal = Some(user_input.chars().take(300).collect());
6676 self.turn_count = self.turn_count.saturating_add(1);
6677 self.emit_compaction_pressure(&tx).await;
6678
6679 {
6681 let (input_end, output_end) = {
6682 let econ = self
6683 .engine
6684 .economics
6685 .lock()
6686 .unwrap_or_else(|p| p.into_inner());
6687 (econ.input_tokens, econ.output_tokens)
6688 };
6689 let context_pct = {
6690 let ctx_len = self.engine.current_context_length();
6691 if ctx_len > 0 {
6692 let total = input_end.saturating_sub(budget_input_start)
6693 + output_end.saturating_sub(budget_output_start);
6694 ((total * 100) / ctx_len).min(100) as u8
6695 } else {
6696 0
6697 }
6698 };
6699 let mut tool_costs: Vec<crate::agent::economics::ToolCost> = Vec::new();
6701 for tc in &budget_tool_costs {
6702 if let Some(existing) = tool_costs.iter_mut().find(|e| e.name == tc.name) {
6703 existing.tokens += tc.tokens;
6704 } else {
6705 tool_costs.push(crate::agent::economics::ToolCost {
6706 name: tc.name.clone(),
6707 tokens: tc.tokens,
6708 });
6709 }
6710 }
6711 let budget = crate::agent::economics::TurnBudget {
6712 input_tokens: input_end.saturating_sub(budget_input_start),
6713 output_tokens: output_end.saturating_sub(budget_output_start),
6714 history_est: budget_history_est,
6715 tool_costs,
6716 context_pct,
6717 };
6718 let _ = tx.send(InferenceEvent::Thought(budget.render())).await;
6719 self.last_turn_budget = Some(budget);
6720 }
6721
6722 if !implement_current_plan {
6724 let tracker = self.diff_tracker.lock().await;
6725 if let Ok(diff) = tracker.generate_diff() {
6726 if !diff.is_empty() {
6727 let _ = tx
6728 .send(InferenceEvent::Thought(format!(
6729 "AUTHORITATIVE TURN SUMMARY:\n\n```diff\n{}\n```",
6730 diff
6731 )))
6732 .await;
6733
6734 self.transcript
6736 .log_system(&format!("Turn Diff Summary:\n{}", diff));
6737 }
6738 }
6739 }
6740
6741 Ok(())
6742 }
6743
6744 async fn emit_runtime_failure(
6745 &mut self,
6746 tx: &mpsc::Sender<InferenceEvent>,
6747 class: RuntimeFailureClass,
6748 detail: &str,
6749 ) {
6750 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6751 let decision = preview_recovery_decision(scenario, &self.recovery_context);
6752 self.emit_recovery_recipe_summary(
6753 tx,
6754 scenario.label(),
6755 compact_recovery_decision_summary(&decision),
6756 )
6757 .await;
6758 let needs_refresh = match &decision {
6759 RecoveryDecision::Attempt(plan) => plan
6760 .recipe
6761 .steps
6762 .contains(&RecoveryStep::RefreshRuntimeProfile),
6763 RecoveryDecision::Escalate { recipe, .. } => {
6764 recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
6765 }
6766 };
6767 if needs_refresh {
6768 if let Some((model_id, context_length, changed)) = self
6769 .refresh_runtime_profile_and_report(tx, "context_window_failure")
6770 .await
6771 {
6772 let note = if changed {
6773 format!(
6774 "Runtime refresh after context-window failure: model {} | CTX {}",
6775 model_id, context_length
6776 )
6777 } else {
6778 format!(
6779 "Runtime refresh after context-window failure confirms model {} | CTX {}",
6780 model_id, context_length
6781 )
6782 };
6783 let _ = tx.send(InferenceEvent::Thought(note)).await;
6784 }
6785 }
6786 }
6787 if let Some(state) = provider_state_for_runtime_failure(class) {
6788 let _ = tx
6789 .send(InferenceEvent::ProviderStatus {
6790 state,
6791 summary: compact_runtime_failure_summary(class).into(),
6792 })
6793 .await;
6794 }
6795 if let Some(state) = checkpoint_state_for_runtime_failure(class) {
6796 self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
6797 .await;
6798 }
6799 let formatted = format_runtime_failure(class, detail);
6800 self.history.push(ChatMessage::system(&format!(
6801 "# RUNTIME FAILURE\n{}",
6802 formatted
6803 )));
6804 self.transcript.log_system(&formatted);
6805 let _ = tx.send(InferenceEvent::Error(formatted)).await;
6806 let _ = tx.send(InferenceEvent::Done).await;
6807 }
6808
6809 async fn auto_verify_workspace(
6812 &self,
6813 mutated_paths: &std::collections::BTreeSet<String>,
6814 ) -> AutoVerificationOutcome {
6815 let root = crate::tools::file_ops::workspace_root();
6816 let profile = crate::agent::workspace_profile::load_workspace_profile(&root)
6817 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(&root));
6818
6819 let mut sections = Vec::new();
6820 let mut overall_ok = true;
6821 let contract = profile.runtime_contract.as_ref();
6822 let verification_workflows: Vec<String> = match contract {
6823 Some(contract) if !contract.verification_workflows.is_empty() => {
6824 contract.verification_workflows.clone()
6825 }
6826 _ if profile.build_hint.is_some() || profile.verify_profile.is_some() => {
6827 vec!["build".to_string()]
6828 }
6829 _ => Vec::new(),
6830 };
6831
6832 for workflow in verification_workflows {
6833 if !should_run_contract_verification_workflow(contract, &workflow, mutated_paths) {
6834 continue;
6835 }
6836 let outcome = self.auto_run_verification_workflow(&workflow).await;
6837 overall_ok &= outcome.ok;
6838 sections.push(outcome.summary);
6839 }
6840
6841 if sections.is_empty() {
6842 sections.push(
6843 "[verify]\nVERIFICATION SKIPPED: Workspace profile does not define an automatic verification workflow for this stack."
6844 .to_string(),
6845 );
6846 }
6847
6848 let header = if overall_ok {
6849 "WORKSPACE VERIFICATION SUCCESS: Automatic validation passed."
6850 } else {
6851 "WORKSPACE VERIFICATION FAILURE: Automatic validation found problems."
6852 };
6853
6854 AutoVerificationOutcome {
6855 ok: overall_ok,
6856 summary: format!("{}\n\n{}", header, sections.join("\n\n")),
6857 }
6858 }
6859
6860 async fn auto_run_verification_workflow(&self, workflow: &str) -> AutoVerificationOutcome {
6861 match workflow {
6862 "build" | "test" | "lint" | "fix" => {
6863 match crate::tools::verify_build::execute(
6864 &serde_json::json!({ "action": workflow }),
6865 )
6866 .await
6867 {
6868 Ok(out) => AutoVerificationOutcome {
6869 ok: true,
6870 summary: format!(
6871 "[{}]\n{} SUCCESS: Automatic {} verification passed.\n\n{}",
6872 workflow,
6873 workflow.to_ascii_uppercase(),
6874 workflow,
6875 cap_output(&out, 2000)
6876 ),
6877 },
6878 Err(e) => AutoVerificationOutcome {
6879 ok: false,
6880 summary: format!(
6881 "[{}]\n{} FAILURE: Automatic {} verification failed.\n\n{}",
6882 workflow,
6883 workflow.to_ascii_uppercase(),
6884 workflow,
6885 cap_output(&e, 2000)
6886 ),
6887 },
6888 }
6889 }
6890 other => {
6891 let args = serde_json::json!({ "workflow": other });
6893 match crate::tools::workspace_workflow::run_workspace_workflow(&args).await {
6894 Ok(out) => {
6895 let ok = !out.contains("Result: FAIL") && !out.contains("Error:");
6898 AutoVerificationOutcome {
6899 ok,
6900 summary: format!("[{}]\n{}", other, out.trim()),
6901 }
6902 }
6903 Err(e) => {
6904 let needs_boot = e.contains("No tracked website server labeled")
6908 || e.contains("HTTP probe failed")
6909 || e.contains("Connection refused")
6910 || e.contains("error trying to connect");
6911
6912 if other == "website_validate" && needs_boot {
6913 let start_args = serde_json::json!({ "workflow": "website_start" });
6914 if let Ok(_) = crate::tools::workspace_workflow::run_workspace_workflow(
6915 &start_args,
6916 )
6917 .await
6918 {
6919 if let Ok(retry_out) =
6920 crate::tools::workspace_workflow::run_workspace_workflow(&args)
6921 .await
6922 {
6923 let ok = !retry_out.contains("Result: FAIL")
6924 && !retry_out.contains("Error:");
6925 return AutoVerificationOutcome {
6926 ok,
6927 summary: format!(
6928 "[{}]\n(Auto-booted) {}",
6929 other,
6930 retry_out.trim()
6931 ),
6932 };
6933 }
6934 }
6935 }
6936
6937 AutoVerificationOutcome {
6938 ok: false,
6939 summary: format!("[{}]\nVERIFICATION FAILURE: {}", other, e),
6940 }
6941 }
6942 }
6943 }
6944 }
6945 }
6946
6947 async fn compact_history_if_needed(
6951 &mut self,
6952 tx: &mpsc::Sender<InferenceEvent>,
6953 anchor_index: Option<usize>,
6954 ) -> Result<bool, String> {
6955 let vram_ratio = self.gpu_state.ratio();
6956 let context_length = self.engine.current_context_length();
6957 let config = CompactionConfig::adaptive(context_length, vram_ratio);
6958
6959 if !compaction::should_compact(&self.history, context_length, vram_ratio) {
6960 return Ok(false);
6961 }
6962
6963 let _ = tx
6964 .send(InferenceEvent::Thought(format!(
6965 "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
6966 context_length / 1000,
6967 vram_ratio * 100.0,
6968 config.max_estimated_tokens / 1000,
6969 )))
6970 .await;
6971
6972 let result = compaction::compact_history(
6973 &self.history,
6974 self.running_summary.as_deref(),
6975 config,
6976 anchor_index,
6977 );
6978
6979 let removed_message_count = self.history.len().saturating_sub(result.messages.len());
6980 self.history = result.messages;
6981 self.running_summary = result.summary;
6982
6983 let previous_memory = self.session_memory.clone();
6985 self.session_memory = compaction::extract_memory(&self.history);
6986 self.session_memory
6987 .inherit_runtime_ledger_from(&previous_memory);
6988 self.session_memory.record_compaction(
6989 removed_message_count,
6990 format!(
6991 "Compacted history around active task '{}' and preserved {} working-set file(s).",
6992 self.session_memory.current_task,
6993 self.session_memory.working_set.len()
6994 ),
6995 );
6996 self.emit_compaction_pressure(tx).await;
6997
6998 let first_non_sys = self
7001 .history
7002 .iter()
7003 .position(|m| m.role != "system")
7004 .unwrap_or(self.history.len());
7005 if first_non_sys < self.history.len() {
7006 if let Some(user_offset) = self.history[first_non_sys..]
7007 .iter()
7008 .position(|m| m.role == "user")
7009 {
7010 if user_offset > 0 {
7011 self.history
7012 .drain(first_non_sys..first_non_sys + user_offset);
7013 }
7014 }
7015 }
7016
7017 let _ = tx
7018 .send(InferenceEvent::Thought(format!(
7019 "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
7020 self.session_memory.current_task,
7021 self.session_memory.working_set.len()
7022 )))
7023 .await;
7024 let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
7025 self.emit_recovery_recipe_summary(
7026 tx,
7027 recipe.recipe.scenario.label(),
7028 compact_recovery_plan_summary(&recipe),
7029 )
7030 .await;
7031 self.emit_operator_checkpoint(
7032 tx,
7033 OperatorCheckpointState::HistoryCompacted,
7034 format!(
7035 "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
7036 self.session_memory.current_task,
7037 self.session_memory.working_set.len()
7038 ),
7039 )
7040 .await;
7041
7042 Ok(true)
7043 }
7044
7045 fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
7049 if query.trim().split_whitespace().count() < 3 {
7051 return None;
7052 }
7053
7054 let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
7055 if results.is_empty() {
7056 return None;
7057 }
7058
7059 let semantic_active = self.vein.has_any_embeddings();
7060 let header = if semantic_active {
7061 "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
7062 Use this to answer without needing extra read_file calls where possible.\n\n"
7063 } else {
7064 "# Relevant context from The Vein (BM25 keyword retrieval)\n\
7065 Use this to answer without needing extra read_file calls where possible.\n\n"
7066 };
7067
7068 let mut ctx = String::from(header);
7069 let mut paths: Vec<String> = Vec::new();
7070
7071 let mut total = 0usize;
7072 const MAX_CTX_CHARS: usize = 1_500;
7073
7074 for r in results {
7075 if total >= MAX_CTX_CHARS {
7076 break;
7077 }
7078 let snippet = if r.content.len() > 500 {
7079 format!("{}...", &r.content[..500])
7080 } else {
7081 r.content.clone()
7082 };
7083 ctx.push_str(&format!("--- {} ---\n{}\n\n", r.path, snippet));
7084 total += snippet.len() + r.path.len() + 10;
7085 if !paths.contains(&r.path) {
7086 paths.push(r.path);
7087 }
7088 }
7089
7090 Some((ctx, paths))
7091 }
7092
7093 fn context_window_slice(&self) -> Vec<ChatMessage> {
7096 let mut result = Vec::new();
7097
7098 if self.history.len() > 1 {
7100 for m in &self.history[1..] {
7101 if m.role == "system" {
7102 continue;
7103 }
7104
7105 let mut sanitized = m.clone();
7106 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7108 sanitized.content = MessageContent::Text(" ".into());
7109 }
7110 result.push(sanitized);
7111 }
7112 }
7113
7114 if !result.is_empty() && result[0].role != "user" {
7117 result.insert(0, ChatMessage::user("Continuing previous context..."));
7118 }
7119
7120 result
7121 }
7122
7123 fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
7124 let mut result = Vec::new();
7125
7126 if self.history.len() > 1 {
7127 let start = start_idx.max(1).min(self.history.len());
7128 for m in &self.history[start..] {
7129 if m.role == "system" {
7130 continue;
7131 }
7132
7133 let mut sanitized = m.clone();
7134 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7135 sanitized.content = MessageContent::Text(" ".into());
7136 }
7137 result.push(sanitized);
7138 }
7139 }
7140
7141 if !result.is_empty() && result[0].role != "user" {
7142 result.insert(0, ChatMessage::user("Continuing current plan execution..."));
7143 }
7144
7145 result
7146 }
7147
7148 fn trim_history(&mut self, max_messages: usize) {
7150 if self.history.len() <= max_messages {
7151 return;
7152 }
7153 let excess = self.history.len() - max_messages;
7155 self.history.drain(1..=excess);
7156 }
7157
7158 #[allow(dead_code)]
7160 async fn repair_tool_args(
7161 &self,
7162 tool_name: &str,
7163 bad_json: &str,
7164 tx: &mpsc::Sender<InferenceEvent>,
7165 ) -> Result<Value, String> {
7166 let _ = tx
7167 .send(InferenceEvent::Thought(format!(
7168 "Attempting to repair malformed JSON for '{}'...",
7169 tool_name
7170 )))
7171 .await;
7172
7173 let prompt = format!(
7174 "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.",
7175 tool_name, bad_json
7176 );
7177
7178 let messages = vec![
7179 ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
7180 ChatMessage::user(&prompt),
7181 ];
7182
7183 let (text, _, _, _) = self
7185 .engine
7186 .call_with_tools(&messages, &[], self.fast_model.as_deref())
7187 .await
7188 .map_err(|e| e.to_string())?;
7189
7190 let cleaned = text
7191 .unwrap_or_default()
7192 .trim()
7193 .trim_start_matches("```json")
7194 .trim_start_matches("```")
7195 .trim_end_matches("```")
7196 .trim()
7197 .to_string();
7198
7199 serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
7200 }
7201
7202 async fn run_critic_check(
7204 &self,
7205 path: &str,
7206 content: &str,
7207 tx: &mpsc::Sender<InferenceEvent>,
7208 ) -> Option<String> {
7209 let ext = std::path::Path::new(path)
7211 .extension()
7212 .and_then(|e| e.to_str())
7213 .unwrap_or("");
7214 const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
7215 if !CRITIC_EXTS.contains(&ext) {
7216 return None;
7217 }
7218
7219 let _ = tx
7220 .send(InferenceEvent::Thought(format!(
7221 "CRITIC: Reviewing changes to '{}'...",
7222 path
7223 )))
7224 .await;
7225
7226 let truncated = cap_output(content, 4000);
7227
7228 const WEB_EXTS_CRITIC: &[&str] = &[
7229 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
7230 ];
7231 let is_web_file = WEB_EXTS_CRITIC.contains(&ext);
7232
7233 let prompt = if is_web_file {
7234 format!(
7235 "You are a senior web developer doing a quality review of '{}'. \
7236 Identify ONLY real problems — missing, broken, or incomplete things that would \
7237 make this file not work or look bad in production. Check:\n\
7238 - HTML: missing DOCTYPE/charset/title/viewport meta, broken links, missing aria, unsemantic structure\n\
7239 - CSS: hardcoded px instead of responsive units, missing mobile media queries, class names used in HTML but not defined here\n\
7240 - JS/TS: missing error handling, undefined variables, console.log left in, DOM elements referenced that may not exist\n\
7241 - All: placeholder text/colors/lorem-ipsum left in, TODO comments, empty sections\n\
7242 Be extremely concise. List issues as short bullets. If everything is production-ready, output 'PASS'.\n\n\
7243 ```{}\n{}\n```",
7244 path, ext, truncated
7245 )
7246 } else {
7247 format!(
7248 "You are a Senior Security and Code Quality auditor. Review this file content for '{}' \
7249 and identify any critical logic errors, security vulnerabilities, or missing error handling. \
7250 Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
7251 path, ext, truncated
7252 )
7253 };
7254
7255 let messages = vec![
7256 ChatMessage::system("You are a technical critic. Identify ONLY real issues that need fixing. Output 'PASS' if none found."),
7257 ChatMessage::user(&prompt)
7258 ];
7259
7260 let (text, _, _, _) = self
7261 .engine
7262 .call_with_tools(&messages, &[], self.fast_model.as_deref())
7263 .await
7264 .ok()?;
7265
7266 let critique = text?.trim().to_string();
7267 if critique.to_uppercase().contains("PASS") || critique.is_empty() {
7268 None
7269 } else {
7270 Some(critique)
7271 }
7272 }
7273}
7274
7275pub async fn dispatch_tool(
7278 name: &str,
7279 args: &Value,
7280 config: &crate::agent::config::HematiteConfig,
7281 budget_tokens: usize,
7282) -> Result<String, String> {
7283 dispatch_builtin_tool(name, args, config, budget_tokens).await
7284}
7285
7286fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
7287 let trimmed = text.trim();
7288 let stripped = trimmed
7289 .strip_prefix("/think")
7290 .or_else(|| trimmed.strip_prefix("/no_think"))
7291 .map(str::trim)
7292 .unwrap_or(trimmed)
7293 .trim_start_matches('\n')
7294 .trim();
7295 (!stripped.is_empty()).then(|| stripped.to_string())
7296}
7297
7298fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
7299 if tool_name != "inspect_host" {
7300 return;
7301 }
7302
7303 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7304 return;
7305 };
7306 if topic != "fix_plan" {
7307 return;
7308 }
7309
7310 let issue_missing = args
7311 .get("issue")
7312 .and_then(|v| v.as_str())
7313 .map(str::trim)
7314 .is_none_or(|value| value.is_empty());
7315 if !issue_missing {
7316 return;
7317 }
7318
7319 let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
7320 return;
7321 };
7322
7323 let Value::Object(map) = args else {
7324 return;
7325 };
7326 map.insert(
7327 "issue".to_string(),
7328 Value::String(fallback_issue.to_string()),
7329 );
7330}
7331
7332fn fill_missing_dns_lookup_name(
7333 tool_name: &str,
7334 args: &mut Value,
7335 latest_user_prompt: Option<&str>,
7336) {
7337 if tool_name != "inspect_host" {
7338 return;
7339 }
7340
7341 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7342 return;
7343 };
7344 if topic != "dns_lookup" {
7345 return;
7346 }
7347
7348 let name_missing = args
7349 .get("name")
7350 .and_then(|v| v.as_str())
7351 .map(str::trim)
7352 .is_none_or(|value| value.is_empty());
7353 if !name_missing {
7354 return;
7355 }
7356
7357 let Some(prompt) = latest_user_prompt else {
7358 return;
7359 };
7360 let Some(name) = extract_dns_lookup_target_from_text(prompt) else {
7361 return;
7362 };
7363
7364 let Value::Object(map) = args else {
7365 return;
7366 };
7367 map.insert("name".to_string(), Value::String(name));
7368}
7369
7370fn fill_missing_dns_lookup_type(
7371 tool_name: &str,
7372 args: &mut Value,
7373 latest_user_prompt: Option<&str>,
7374) {
7375 if tool_name != "inspect_host" {
7376 return;
7377 }
7378
7379 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7380 return;
7381 };
7382 if topic != "dns_lookup" {
7383 return;
7384 }
7385
7386 let type_missing = args
7387 .get("type")
7388 .and_then(|v| v.as_str())
7389 .map(str::trim)
7390 .is_none_or(|value| value.is_empty());
7391 if !type_missing {
7392 return;
7393 }
7394
7395 let record_type = latest_user_prompt
7396 .and_then(extract_dns_record_type_from_text)
7397 .unwrap_or("A");
7398
7399 let Value::Object(map) = args else {
7400 return;
7401 };
7402 map.insert("type".to_string(), Value::String(record_type.to_string()));
7403}
7404
7405fn fill_missing_event_query_args(
7406 tool_name: &str,
7407 args: &mut Value,
7408 latest_user_prompt: Option<&str>,
7409) {
7410 if tool_name != "inspect_host" {
7411 return;
7412 }
7413
7414 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7415 return;
7416 };
7417 if topic != "event_query" {
7418 return;
7419 }
7420
7421 let Some(prompt) = latest_user_prompt else {
7422 return;
7423 };
7424
7425 let Value::Object(map) = args else {
7426 return;
7427 };
7428
7429 let event_id_missing = map.get("event_id").and_then(|v| v.as_u64()).is_none();
7430 if event_id_missing {
7431 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7432 map.insert(
7433 "event_id".to_string(),
7434 Value::Number(serde_json::Number::from(event_id)),
7435 );
7436 }
7437 }
7438
7439 let log_missing = map
7440 .get("log")
7441 .and_then(|v| v.as_str())
7442 .map(str::trim)
7443 .is_none_or(|value| value.is_empty());
7444 if log_missing {
7445 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7446 map.insert("log".to_string(), Value::String(log_name.to_string()));
7447 }
7448 }
7449
7450 let level_missing = map
7451 .get("level")
7452 .and_then(|v| v.as_str())
7453 .map(str::trim)
7454 .is_none_or(|value| value.is_empty());
7455 if level_missing {
7456 if let Some(level) = extract_event_query_level_from_text(prompt) {
7457 map.insert("level".to_string(), Value::String(level.to_string()));
7458 }
7459 }
7460
7461 let hours_missing = map.get("hours").and_then(|v| v.as_u64()).is_none();
7462 if hours_missing {
7463 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7464 map.insert(
7465 "hours".to_string(),
7466 Value::Number(serde_json::Number::from(hours)),
7467 );
7468 }
7469 }
7470}
7471
7472fn should_rewrite_shell_to_fix_plan(
7473 tool_name: &str,
7474 args: &Value,
7475 latest_user_prompt: Option<&str>,
7476) -> bool {
7477 if tool_name != "shell" {
7478 return false;
7479 }
7480 let Some(prompt) = latest_user_prompt else {
7481 return false;
7482 };
7483 if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
7484 return false;
7485 }
7486 let command = args
7487 .get("command")
7488 .and_then(|value| value.as_str())
7489 .unwrap_or("");
7490 shell_looks_like_structured_host_inspection(command)
7491}
7492
7493fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
7494 let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(flag));
7495 let regex = regex::Regex::new(&pattern).ok()?;
7496 let captures = regex.captures(command)?;
7497 captures.get(1).map(|m| m.as_str().to_string())
7498}
7499
7500fn clean_shell_dns_token(token: &str) -> String {
7501 token
7502 .trim_matches(|c: char| {
7503 c.is_whitespace()
7504 || matches!(
7505 c,
7506 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
7507 )
7508 })
7509 .trim_end_matches(|c: char| matches!(c, ':' | '.'))
7510 .to_string()
7511}
7512
7513fn looks_like_dns_target(token: &str) -> bool {
7514 let cleaned = clean_shell_dns_token(token);
7515 if cleaned.is_empty() {
7516 return false;
7517 }
7518
7519 let lower = cleaned.to_ascii_lowercase();
7520 if matches!(
7521 lower.as_str(),
7522 "a" | "aaaa"
7523 | "mx"
7524 | "srv"
7525 | "txt"
7526 | "cname"
7527 | "ptr"
7528 | "soa"
7529 | "any"
7530 | "resolve-dnsname"
7531 | "nslookup"
7532 | "host"
7533 | "dig"
7534 | "powershell"
7535 | "-command"
7536 | "foreach-object"
7537 | "select-object"
7538 | "address"
7539 | "ipaddress"
7540 | "name"
7541 | "type"
7542 ) {
7543 return false;
7544 }
7545
7546 if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
7547 return true;
7548 }
7549
7550 cleaned.contains('.')
7551 && cleaned
7552 .chars()
7553 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
7554}
7555
7556fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
7557 for pattern in [
7558 r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#,
7559 r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#,
7560 r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
7561 ] {
7562 let regex = regex::Regex::new(pattern).ok()?;
7563 if let Some(value) = regex
7564 .captures(command)
7565 .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
7566 .filter(|value| looks_like_dns_target(value))
7567 {
7568 return Some(value);
7569 }
7570 }
7571
7572 let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
7573 for captures in quoted.captures_iter(command) {
7574 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7575 if looks_like_dns_target(&candidate) {
7576 return Some(candidate);
7577 }
7578 }
7579
7580 command
7581 .split_whitespace()
7582 .map(clean_shell_dns_token)
7583 .find(|token| looks_like_dns_target(token))
7584}
7585
7586fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
7587 let quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok()?;
7588 for captures in quoted.captures_iter(text) {
7589 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7590 if looks_like_dns_target(&candidate) {
7591 return Some(candidate);
7592 }
7593 }
7594
7595 text.split_whitespace()
7596 .map(clean_shell_dns_token)
7597 .find(|token| looks_like_dns_target(token))
7598}
7599
7600fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
7601 let lower = text.to_ascii_lowercase();
7602 if lower.contains("aaaa record") || lower.contains("ipv6 address") {
7603 Some("AAAA")
7604 } else if lower.contains("mx record") {
7605 Some("MX")
7606 } else if lower.contains("srv record") {
7607 Some("SRV")
7608 } else if lower.contains("txt record") {
7609 Some("TXT")
7610 } else if lower.contains("cname record") {
7611 Some("CNAME")
7612 } else if lower.contains("soa record") {
7613 Some("SOA")
7614 } else if lower.contains("ptr record") {
7615 Some("PTR")
7616 } else if lower.contains("a record")
7617 || (lower.contains("ip address") && lower.contains(" of "))
7618 || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
7619 {
7620 Some("A")
7621 } else {
7622 None
7623 }
7624}
7625
7626fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
7627 let re = regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").ok()?;
7628 re.captures(text)
7629 .and_then(|captures| captures.get(1))
7630 .and_then(|m| m.as_str().parse::<u32>().ok())
7631}
7632
7633fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
7634 let lower = text.to_ascii_lowercase();
7635 if lower.contains("security log") {
7636 Some("Security")
7637 } else if lower.contains("application log") {
7638 Some("Application")
7639 } else if lower.contains("system log") || lower.contains("system errors") {
7640 Some("System")
7641 } else if lower.contains("setup log") {
7642 Some("Setup")
7643 } else {
7644 None
7645 }
7646}
7647
7648fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
7649 let lower = text.to_ascii_lowercase();
7650 if lower.contains("critical") {
7651 Some("Critical")
7652 } else if lower.contains("error") || lower.contains("errors") {
7653 Some("Error")
7654 } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
7655 Some("Warning")
7656 } else if lower.contains("information")
7657 || lower.contains("informational")
7658 || lower.contains("info")
7659 {
7660 Some("Information")
7661 } else {
7662 None
7663 }
7664}
7665
7666fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
7667 let lower = text.to_ascii_lowercase();
7668 let re = regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b").ok()?;
7669 if let Some(hours) = re
7670 .captures(&lower)
7671 .and_then(|captures| captures.get(1))
7672 .and_then(|m| m.as_str().parse::<u32>().ok())
7673 {
7674 return Some(hours);
7675 }
7676 if lower.contains("last hour") || lower.contains("past hour") {
7677 Some(1)
7678 } else if lower.contains("today") {
7679 Some(24)
7680 } else {
7681 None
7682 }
7683}
7684
7685fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
7686 let lower = command.to_ascii_lowercase();
7687 if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
7688 Some("AAAA")
7689 } else if lower.contains("-type mx") || lower.contains("-type=mx") {
7690 Some("MX")
7691 } else if lower.contains("-type srv") || lower.contains("-type=srv") {
7692 Some("SRV")
7693 } else if lower.contains("-type txt") || lower.contains("-type=txt") {
7694 Some("TXT")
7695 } else if lower.contains("-type cname") || lower.contains("-type=cname") {
7696 Some("CNAME")
7697 } else if lower.contains("-type soa") || lower.contains("-type=soa") {
7698 Some("SOA")
7699 } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
7700 Some("PTR")
7701 } else if lower.contains("-type a") || lower.contains("-type=a") {
7702 Some("A")
7703 } else {
7704 extract_dns_record_type_from_text(command)
7705 }
7706}
7707
7708fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
7709 let mut args = serde_json::json!({ "topic": topic });
7710 if topic == "dns_lookup" {
7711 if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
7712 args.as_object_mut()
7713 .unwrap()
7714 .insert("name".to_string(), Value::String(name));
7715 }
7716 let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
7717 args.as_object_mut()
7718 .unwrap()
7719 .insert("type".to_string(), Value::String(record_type.to_string()));
7720 } else if topic == "event_query" {
7721 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7722 args.as_object_mut().unwrap().insert(
7723 "event_id".to_string(),
7724 Value::Number(serde_json::Number::from(event_id)),
7725 );
7726 }
7727 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7728 args.as_object_mut()
7729 .unwrap()
7730 .insert("log".to_string(), Value::String(log_name.to_string()));
7731 }
7732 if let Some(level) = extract_event_query_level_from_text(prompt) {
7733 args.as_object_mut()
7734 .unwrap()
7735 .insert("level".to_string(), Value::String(level.to_string()));
7736 }
7737 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7738 args.as_object_mut().unwrap().insert(
7739 "hours".to_string(),
7740 Value::Number(serde_json::Number::from(hours)),
7741 );
7742 }
7743 }
7744 args
7745}
7746
7747fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7748 let workflow = preferred_maintainer_workflow(prompt)?;
7749 let lower = prompt.to_ascii_lowercase();
7750 match workflow {
7751 "clean" => Some(serde_json::json!({
7752 "workflow": "clean",
7753 "deep": lower.contains("deep clean")
7754 || lower.contains("deep cleanup")
7755 || lower.contains("deep"),
7756 "reset": lower.contains("reset"),
7757 "prune_dist": lower.contains("prune dist")
7758 || lower.contains("prune old dist")
7759 || lower.contains("prune old artifacts")
7760 || lower.contains("old dist artifacts")
7761 || lower.contains("old artifacts"),
7762 })),
7763 "package_windows" => Some(serde_json::json!({
7764 "workflow": "package_windows",
7765 "installer": lower.contains("installer") || lower.contains("setup.exe"),
7766 "add_to_path": lower.contains("addtopath")
7767 || lower.contains("add to path")
7768 || lower.contains("update path")
7769 || lower.contains("refresh path"),
7770 })),
7771 "release" => {
7772 let version = regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#)
7773 .ok()
7774 .and_then(|re| re.captures(prompt))
7775 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
7776 let bump = if lower.contains("patch") {
7777 Some("patch")
7778 } else if lower.contains("minor") {
7779 Some("minor")
7780 } else if lower.contains("major") {
7781 Some("major")
7782 } else {
7783 None
7784 };
7785 let mut args = serde_json::json!({
7786 "workflow": "release",
7787 "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
7788 "add_to_path": lower.contains("addtopath")
7789 || lower.contains("add to path")
7790 || lower.contains("update path"),
7791 "skip_installer": lower.contains("skip installer"),
7792 "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
7793 "publish_voice_crate": lower.contains("publish voice crate")
7794 || lower.contains("publish hematite-kokoros"),
7795 });
7796 if let Some(version) = version {
7797 args["version"] = Value::String(version);
7798 }
7799 if let Some(bump) = bump {
7800 args["bump"] = Value::String(bump.to_string());
7801 }
7802 Some(args)
7803 }
7804 _ => None,
7805 }
7806}
7807
7808fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7809 if is_scaffold_request(prompt) {
7810 return None;
7811 }
7812 let workflow = preferred_workspace_workflow(prompt)?;
7813 let lower = prompt.to_ascii_lowercase();
7814 let trimmed = prompt.trim();
7815
7816 if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
7817 return Some(serde_json::json!({
7818 "workflow": "command",
7819 "command": command,
7820 }));
7821 }
7822
7823 if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
7824 return Some(serde_json::json!({
7825 "workflow": "script_path",
7826 "path": path,
7827 }));
7828 }
7829
7830 match workflow {
7831 "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
7832 "workflow": workflow,
7833 })),
7834 "script" => {
7835 let package_script = if lower.contains("npm run ") {
7836 extract_word_after(&lower, "npm run ")
7837 } else if lower.contains("pnpm run ") {
7838 extract_word_after(&lower, "pnpm run ")
7839 } else if lower.contains("bun run ") {
7840 extract_word_after(&lower, "bun run ")
7841 } else if lower.contains("yarn ") {
7842 extract_word_after(&lower, "yarn ")
7843 } else {
7844 None
7845 };
7846
7847 if let Some(name) = package_script {
7848 return Some(serde_json::json!({
7849 "workflow": "package_script",
7850 "name": name,
7851 }));
7852 }
7853
7854 if let Some(name) = extract_word_after(&lower, "just ") {
7855 return Some(serde_json::json!({
7856 "workflow": "just",
7857 "name": name,
7858 }));
7859 }
7860 if let Some(name) = extract_word_after(&lower, "make ") {
7861 return Some(serde_json::json!({
7862 "workflow": "make",
7863 "name": name,
7864 }));
7865 }
7866 if let Some(name) = extract_word_after(&lower, "task ") {
7867 return Some(serde_json::json!({
7868 "workflow": "task",
7869 "name": name,
7870 }));
7871 }
7872
7873 None
7874 }
7875 _ => None,
7876 }
7877}
7878
7879fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
7880 let lower = prompt.to_ascii_lowercase();
7881 for prefix in [
7882 "cargo ",
7883 "npm ",
7884 "pnpm ",
7885 "yarn ",
7886 "bun ",
7887 "pytest",
7888 "go build",
7889 "go test",
7890 "make ",
7891 "just ",
7892 "task ",
7893 "./gradlew",
7894 ".\\gradlew",
7895 ] {
7896 if let Some(index) = lower.find(prefix) {
7897 return Some(prompt[index..].trim().trim_matches('`').to_string());
7898 }
7899 }
7900 None
7901}
7902
7903fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
7904 let normalized = prompt.replace('\\', "/");
7905 for token in normalized.split_whitespace() {
7906 let candidate = token
7907 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
7908 .trim_start_matches("./");
7909 if candidate.starts_with("scripts/")
7910 && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
7911 .iter()
7912 .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
7913 {
7914 return Some(candidate.to_string());
7915 }
7916 }
7917 None
7918}
7919
7920fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
7921 let start = haystack.find(prefix)? + prefix.len();
7922 let tail = &haystack[start..];
7923 let word = tail
7924 .split_whitespace()
7925 .next()
7926 .map(str::trim)
7927 .filter(|value| !value.is_empty())?;
7928 Some(
7929 word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
7930 .to_string(),
7931 )
7932}
7933
7934fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
7935 let lower = command.to_ascii_lowercase();
7936 if lower.contains("clean.ps1") {
7937 return Some(serde_json::json!({
7938 "workflow": "clean",
7939 "deep": lower.contains("-deep"),
7940 "reset": lower.contains("-reset"),
7941 "prune_dist": lower.contains("-prunedist"),
7942 }));
7943 }
7944 if lower.contains("package-windows.ps1") {
7945 return Some(serde_json::json!({
7946 "workflow": "package_windows",
7947 "installer": lower.contains("-installer"),
7948 "add_to_path": lower.contains("-addtopath"),
7949 }));
7950 }
7951 if lower.contains("release.ps1") {
7952 let version = extract_release_arg(command, "-Version");
7953 let bump = extract_release_arg(command, "-Bump");
7954 if version.is_none() && bump.is_none() {
7955 return Some(serde_json::json!({
7956 "workflow": "release"
7957 }));
7958 }
7959 let mut args = serde_json::json!({
7960 "workflow": "release",
7961 "push": lower.contains("-push"),
7962 "add_to_path": lower.contains("-addtopath"),
7963 "skip_installer": lower.contains("-skipinstaller"),
7964 "publish_crates": lower.contains("-publishcrates"),
7965 "publish_voice_crate": lower.contains("-publishvoicecrate"),
7966 });
7967 if let Some(version) = version {
7968 args["version"] = Value::String(version);
7969 }
7970 if let Some(bump) = bump {
7971 args["bump"] = Value::String(bump);
7972 }
7973 return Some(args);
7974 }
7975 None
7976}
7977
7978fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
7979 let lower = command.to_ascii_lowercase();
7980 if lower.contains("clean.ps1")
7981 || lower.contains("package-windows.ps1")
7982 || lower.contains("release.ps1")
7983 {
7984 return None;
7985 }
7986
7987 if let Some(path) = extract_workspace_script_path_from_prompt(command) {
7988 return Some(serde_json::json!({
7989 "workflow": "script_path",
7990 "path": path,
7991 }));
7992 }
7993
7994 let looks_like_workspace_command = [
7995 "cargo ",
7996 "npm ",
7997 "pnpm ",
7998 "yarn ",
7999 "bun ",
8000 "pytest",
8001 "go build",
8002 "go test",
8003 "make ",
8004 "just ",
8005 "task ",
8006 "./gradlew",
8007 ".\\gradlew",
8008 ]
8009 .iter()
8010 .any(|needle| lower.contains(needle));
8011
8012 if looks_like_workspace_command {
8013 Some(serde_json::json!({
8014 "workflow": "command",
8015 "command": command.trim(),
8016 }))
8017 } else {
8018 None
8019 }
8020}
8021
8022fn rewrite_host_tool_call(
8023 tool_name: &mut String,
8024 args: &mut Value,
8025 latest_user_prompt: Option<&str>,
8026) {
8027 if *tool_name == "shell" {
8028 let command = args
8029 .get("command")
8030 .and_then(|value| value.as_str())
8031 .unwrap_or("");
8032 if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
8033 *tool_name = "run_hematite_maintainer_workflow".to_string();
8034 *args = maintainer_workflow_args;
8035 return;
8036 }
8037 if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
8038 *tool_name = "run_workspace_workflow".to_string();
8039 *args = workspace_workflow_args;
8040 return;
8041 }
8042 }
8043 let is_surgical_tool = matches!(
8044 tool_name.as_str(),
8045 "create_directory"
8046 | "write_file"
8047 | "edit_file"
8048 | "patch_hunk"
8049 | "multi_replace_file_content"
8050 | "replace_file_content"
8051 | "move_file"
8052 | "delete_file"
8053 );
8054
8055 if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
8056 if let Some(prompt_args) =
8057 latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
8058 {
8059 *tool_name = "run_hematite_maintainer_workflow".to_string();
8060 *args = prompt_args;
8061 return;
8062 }
8063 }
8064 let is_generic_command_trigger = matches!(
8068 tool_name.as_str(),
8069 "shell" | "run_command" | "workflow" | "run"
8070 );
8071 if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
8072 if let Some(prompt_args) =
8073 latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
8074 {
8075 *tool_name = "run_workspace_workflow".to_string();
8076 *args = prompt_args;
8077 return;
8078 }
8079 }
8080 if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
8081 *tool_name = "inspect_host".to_string();
8082 *args = serde_json::json!({
8083 "topic": "fix_plan"
8084 });
8085 }
8086 fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
8087 fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
8088 fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
8089 fill_missing_event_query_args(tool_name, args, latest_user_prompt);
8090}
8091
8092fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
8093 format!(
8094 "{}:{}",
8095 tool_name,
8096 serde_json::to_string(args).unwrap_or_default()
8097 )
8098}
8099
8100fn normalized_tool_call_for_execution(
8101 tool_name: &str,
8102 raw_arguments: &Value,
8103 gemma4_model: bool,
8104 latest_user_prompt: Option<&str>,
8105) -> (String, Value) {
8106 let mut normalized_name = tool_name.to_string();
8107 let mut args = if gemma4_model {
8108 let raw_str = raw_arguments.to_string();
8109 let normalized_str =
8110 crate::agent::inference::normalize_tool_argument_string(tool_name, &raw_str);
8111 serde_json::from_str::<Value>(&normalized_str).unwrap_or_else(|_| raw_arguments.clone())
8112 } else {
8113 raw_arguments.clone()
8114 };
8115 rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
8116 (normalized_name, args)
8117}
8118
8119#[cfg(test)]
8120fn normalized_tool_call_key_for_dedupe(
8121 tool_name: &str,
8122 raw_arguments: &str,
8123 gemma4_model: bool,
8124 latest_user_prompt: Option<&str>,
8125) -> String {
8126 let val = serde_json::from_str(raw_arguments).unwrap_or(Value::Null);
8127 let (normalized_name, args) =
8128 normalized_tool_call_for_execution(tool_name, &val, gemma4_model, latest_user_prompt);
8129 canonical_tool_call_key(&normalized_name, &args)
8130}
8131
8132impl ConversationManager {
8133 fn check_authorization(
8135 &self,
8136 name: &str,
8137 args: &serde_json::Value,
8138 config: &crate::agent::config::HematiteConfig,
8139 yolo_flag: bool,
8140 ) -> crate::agent::permission_enforcer::AuthorizationDecision {
8141 crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
8142 }
8143
8144 async fn process_tool_call(
8146 &self,
8147 mut call: ToolCallFn,
8148 config: crate::agent::config::HematiteConfig,
8149 yolo: bool,
8150 tx: mpsc::Sender<InferenceEvent>,
8151 real_id: String,
8152 budget_tokens: usize,
8153 ) -> ToolExecutionOutcome {
8154 let mut msg_results = Vec::new();
8155 let mut latest_target_dir = None;
8156 let mut plan_drafted_this_turn = false;
8157 let mut parsed_plan_handoff = None;
8158 let gemma4_model =
8159 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
8160 let (normalized_name, mut args) = normalized_tool_call_for_execution(
8161 &call.name,
8162 &call.arguments,
8163 gemma4_model,
8164 self.history
8165 .last()
8166 .and_then(|m| m.content.as_str().split('\n').last()),
8167 );
8168 call.name = normalized_name;
8169 let last_user_prompt = self
8170 .history
8171 .iter()
8172 .rev()
8173 .find(|message| message.role == "user")
8174 .map(|message| message.content.as_str());
8175 rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
8176 if self
8177 .plan_execution_active
8178 .load(std::sync::atomic::Ordering::SeqCst)
8179 {
8180 let fallback_target = self
8181 .session_memory
8182 .current_plan
8183 .as_ref()
8184 .and_then(|plan| plan.target_files.first().map(String::as_str));
8185 let explicit_query = last_user_prompt.and_then(extract_explicit_web_search_query);
8186 if let Some((repaired_args, note)) = repaired_plan_tool_args(
8187 &call.name,
8188 &args,
8189 std::path::Path::new(".hematite/TASK.md").exists(),
8190 fallback_target,
8191 explicit_query.as_deref(),
8192 ) {
8193 args = repaired_args;
8194 let _ = tx.send(InferenceEvent::Thought(note)).await;
8195 }
8196 }
8197
8198 let display = format_tool_display(&call.name, &args);
8199 let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
8200 let auth = self.check_authorization(&call.name, &args, &config, yolo);
8201
8202 let decision_result = match precondition_result {
8204 Err(e) => Err(e),
8205 Ok(_) => match auth {
8206 crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
8207 crate::agent::permission_enforcer::AuthorizationDecision::Ask {
8208 reason,
8209 source: _,
8210 } => {
8211 let mutation_label =
8212 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8213 let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
8214 let _ = tx
8215 .send(InferenceEvent::ApprovalRequired {
8216 id: real_id.clone(),
8217 name: call.name.clone(),
8218 display: format!("{}\nWhy: {}", display, reason),
8219 diff: None,
8220 mutation_label,
8221 responder: approve_tx,
8222 })
8223 .await;
8224
8225 match approve_rx.await {
8226 Ok(true) => Ok(()),
8227 _ => Err("Declined by user".into()),
8228 }
8229 }
8230 crate::agent::permission_enforcer::AuthorizationDecision::Deny {
8231 reason, ..
8232 } => Err(reason),
8233 },
8234 };
8235 let blocked_by_policy =
8236 matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
8237
8238 let (output, is_error) = match decision_result {
8240 Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
8241 Err(e) => (format!("Error: {}", e), true),
8242 Ok(_) => {
8243 let _ = tx
8244 .send(InferenceEvent::ToolCallStart {
8245 id: real_id.clone(),
8246 name: call.name.clone(),
8247 args: display.clone(),
8248 })
8249 .await;
8250
8251 let result = if call.name.starts_with("lsp_") {
8252 let lsp = self.lsp_manager.clone();
8253 let path = args
8254 .get("path")
8255 .and_then(|v| v.as_str())
8256 .unwrap_or("")
8257 .to_string();
8258 let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8259 let character =
8260 args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8261
8262 match call.name.as_str() {
8263 "lsp_definitions" => {
8264 crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
8265 .await
8266 }
8267 "lsp_references" => {
8268 crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
8269 .await
8270 }
8271 "lsp_hover" => {
8272 crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
8273 }
8274 "lsp_search_symbol" => {
8275 let query = args
8276 .get("query")
8277 .and_then(|v| v.as_str())
8278 .unwrap_or_default()
8279 .to_string();
8280 crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
8281 }
8282 "lsp_rename_symbol" => {
8283 let new_name = args
8284 .get("new_name")
8285 .and_then(|v| v.as_str())
8286 .unwrap_or_default()
8287 .to_string();
8288 crate::tools::lsp_tools::lsp_rename_symbol(
8289 lsp, path, line, character, new_name,
8290 )
8291 .await
8292 }
8293 "lsp_get_diagnostics" => {
8294 crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
8295 }
8296 _ => Err(format!("Unknown LSP tool: {}", call.name)),
8297 }
8298 } else if call.name == "auto_pin_context" {
8299 let pts = args.get("paths").and_then(|v| v.as_array());
8300 let reason = args
8301 .get("reason")
8302 .and_then(|v| v.as_str())
8303 .unwrap_or("uninformed scoping");
8304 if let Some(arr) = pts {
8305 let mut pinned = Vec::new();
8306 {
8307 let mut guard = self.pinned_files.lock().await;
8308 const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; for v in arr.iter().take(3) {
8311 if let Some(p) = v.as_str() {
8312 if let Ok(meta) = std::fs::metadata(p) {
8313 if meta.len() > MAX_PINNED_SIZE {
8314 let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
8315 continue;
8316 }
8317 if let Ok(content) = std::fs::read_to_string(p) {
8318 guard.insert(p.to_string(), content);
8319 pinned.push(p.to_string());
8320 }
8321 }
8322 }
8323 }
8324 }
8325 let msg = format!(
8326 "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
8327 pinned.join(", "),
8328 reason
8329 );
8330 let _ = tx
8331 .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
8332 .await;
8333 Ok(msg)
8334 } else {
8335 Err("Missing 'paths' array for auto_pin_context.".to_string())
8336 }
8337 } else if call.name == "list_pinned" {
8338 let paths_msg = {
8339 let pinned = self.pinned_files.lock().await;
8340 if pinned.is_empty() {
8341 "No files are currently pinned.".to_string()
8342 } else {
8343 let paths: Vec<_> = pinned.keys().cloned().collect();
8344 format!(
8345 "Currently pinned files in active memory:\n- {}",
8346 paths.join("\n- ")
8347 )
8348 }
8349 };
8350 Ok(paths_msg)
8351 } else if call.name.starts_with("mcp__") {
8352 let mut mcp = self.mcp_manager.lock().await;
8353 match mcp.call_tool(&call.name, &args).await {
8354 Ok(res) => Ok(res),
8355 Err(e) => Err(e.to_string()),
8356 }
8357 } else if call.name == "swarm" {
8358 let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
8360 let max_workers = args
8361 .get("max_workers")
8362 .and_then(|v| v.as_u64())
8363 .unwrap_or(3) as usize;
8364
8365 let mut task_objs = Vec::new();
8366 if let Value::Array(arr) = tasks_val {
8367 for v in arr {
8368 let id = v
8369 .get("id")
8370 .and_then(|x| x.as_str())
8371 .unwrap_or("?")
8372 .to_string();
8373 let target = v
8374 .get("target")
8375 .and_then(|x| x.as_str())
8376 .unwrap_or("?")
8377 .to_string();
8378 let instruction = v
8379 .get("instruction")
8380 .and_then(|x| x.as_str())
8381 .unwrap_or("?")
8382 .to_string();
8383 task_objs.push(crate::agent::parser::WorkerTask {
8384 id,
8385 target,
8386 instruction,
8387 });
8388 }
8389 }
8390
8391 if task_objs.is_empty() {
8392 Err("No tasks provided for swarm.".to_string())
8393 } else {
8394 let (swarm_tx_internal, mut swarm_rx_internal) =
8395 tokio::sync::mpsc::channel(32);
8396 let tx_forwarder = tx.clone();
8397
8398 tokio::spawn(async move {
8400 while let Some(msg) = swarm_rx_internal.recv().await {
8401 match msg {
8402 crate::agent::swarm::SwarmMessage::Progress(id, p) => {
8403 let _ = tx_forwarder
8404 .send(InferenceEvent::Thought(format!(
8405 "Swarm [{}]: {}% complete",
8406 id, p
8407 )))
8408 .await;
8409 }
8410 crate::agent::swarm::SwarmMessage::ReviewRequest {
8411 worker_id,
8412 file_path,
8413 before: _,
8414 after: _,
8415 tx,
8416 } => {
8417 let (approve_tx, approve_rx) =
8418 tokio::sync::oneshot::channel::<bool>();
8419 let display = format!(
8420 "Swarm worker [{}]: Integrated changes into {:?}",
8421 worker_id, file_path
8422 );
8423 let _ = tx_forwarder
8424 .send(InferenceEvent::ApprovalRequired {
8425 id: format!("swarm_{}", worker_id),
8426 name: "swarm_apply".to_string(),
8427 display,
8428 diff: None,
8429 mutation_label: Some(
8430 "Swarm Agentic Integration".to_string(),
8431 ),
8432 responder: approve_tx,
8433 })
8434 .await;
8435 if let Ok(approved) = approve_rx.await {
8436 let response = if approved {
8437 crate::agent::swarm::ReviewResponse::Accept
8438 } else {
8439 crate::agent::swarm::ReviewResponse::Reject
8440 };
8441 let _ = tx.send(response);
8442 }
8443 }
8444 crate::agent::swarm::SwarmMessage::Done => {}
8445 }
8446 }
8447 });
8448
8449 let coordinator = self.swarm_coordinator.clone();
8450 match coordinator
8451 .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
8452 .await
8453 {
8454 Ok(_) => Ok(
8455 "Swarm execution completed. Check files for integration results."
8456 .to_string(),
8457 ),
8458 Err(e) => Err(format!("Swarm failure: {}", e)),
8459 }
8460 }
8461 } else if call.name == "vision_analyze" {
8462 crate::tools::vision::vision_analyze(&self.engine, &args).await
8463 } else if matches!(
8464 call.name.as_str(),
8465 "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
8466 ) && !yolo
8467 {
8468 let diff_result = match call.name.as_str() {
8474 "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
8475 "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
8476 "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
8477 _ => crate::tools::file_ops::compute_msr_diff(&args),
8478 };
8479 match diff_result {
8480 Ok(diff_text) => {
8481 let path_label =
8482 args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
8483 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
8484 let mutation_label =
8485 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8486 let _ = tx
8487 .send(InferenceEvent::ApprovalRequired {
8488 id: real_id.clone(),
8489 name: call.name.clone(),
8490 display: format!("Edit preview: {}", path_label),
8491 diff: Some(diff_text),
8492 mutation_label,
8493 responder: appr_tx,
8494 })
8495 .await;
8496 match appr_rx.await {
8497 Ok(true) => {
8498 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8499 }
8500 _ => Err("Edit declined by user.".into()),
8501 }
8502 }
8503 Err(_) => dispatch_tool(&call.name, &args, &config, budget_tokens).await,
8506 }
8507 } else if call.name == "verify_build" {
8508 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
8511 } else if call.name == "shell" {
8512 crate::tools::shell::execute_streaming(&args, tx.clone(), budget_tokens).await
8515 } else {
8516 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8517 };
8518
8519 match result {
8520 Ok(o) => (o, false),
8521 Err(e) => (format!("Error: {}", e), true),
8522 }
8523 }
8524 };
8525
8526 {
8528 if let Ok(mut econ) = self.engine.economics.lock() {
8529 econ.record_tool(&call.name, !is_error);
8530 }
8531 }
8532
8533 if !is_error {
8534 if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
8535 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8536 if call.name == "inspect_lines" {
8537 self.record_line_inspection(path).await;
8538 } else {
8539 self.record_read_observation(path).await;
8540 }
8541 }
8542 }
8543
8544 if call.name == "verify_build" {
8545 let ok = output.contains("BUILD OK")
8546 || output.contains("BUILD SUCCESS")
8547 || output.contains("BUILD OKAY");
8548 self.record_verify_build_result(ok, &output).await;
8549 }
8550
8551 if matches!(
8552 call.name.as_str(),
8553 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
8554 ) || is_mcp_mutating_tool(&call.name)
8555 {
8556 if call.name == "write_file" {
8557 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8558 if path.ends_with("PLAN.md") {
8559 plan_drafted_this_turn = true;
8560 if !is_error {
8561 if let Some(content) = args.get("content").and_then(|v| v.as_str())
8562 {
8563 let resolved = crate::tools::file_ops::resolve_candidate(path);
8564 let _ = crate::tools::plan::sync_plan_blueprint_for_path(
8565 &resolved, content,
8566 );
8567 parsed_plan_handoff =
8568 crate::tools::plan::parse_plan_handoff(content);
8569 }
8570 }
8571 }
8572 }
8573 }
8574 self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
8575 .await;
8576 }
8577
8578 if call.name == "create_directory" {
8579 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8580 let resolved = crate::tools::file_ops::resolve_candidate(path);
8581 latest_target_dir = Some(resolved.to_string_lossy().to_string());
8582 }
8583 }
8584
8585 if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
8586 msg_results.push(receipt);
8587 }
8588 }
8589
8590 if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
8594 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
8595 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
8596 let ext = std::path::Path::new(path)
8597 .extension()
8598 .and_then(|e| e.to_str())
8599 .unwrap_or("");
8600 const SKIP_EXTS: &[&str] = &[
8601 "md",
8602 "toml",
8603 "json",
8604 "txt",
8605 "yml",
8606 "yaml",
8607 "cfg",
8608 "csv",
8609 "lock",
8610 "gitignore",
8611 ];
8612 let line_count = content.lines().count();
8613 const WEB_EXTS: &[&str] = &[
8616 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
8617 ];
8618 let is_web = WEB_EXTS.contains(&ext);
8619 let min_lines = if is_web { 5 } else { 50 };
8620 if !path.is_empty()
8621 && !content.is_empty()
8622 && !SKIP_EXTS.contains(&ext)
8623 && line_count >= min_lines
8624 {
8625 if let Some(critique) = self.run_critic_check(path, content, &tx).await {
8626 msg_results.push(ChatMessage::system(&format!(
8627 "[CRITIC AUTO-FIX REQUIRED — {}]\n\
8628 Fix ALL issues below before sending your final response. \
8629 Call the appropriate edit tools now.\n\n{}",
8630 path, critique
8631 )));
8632 }
8633 }
8634 }
8635
8636 ToolExecutionOutcome {
8637 call_id: real_id,
8638 tool_name: call.name,
8639 args,
8640 output,
8641 is_error,
8642 blocked_by_policy,
8643 msg_results,
8644 latest_target_dir,
8645 plan_drafted_this_turn,
8646 parsed_plan_handoff,
8647 }
8648 }
8649}
8650
8651struct ToolExecutionOutcome {
8654 call_id: String,
8655 tool_name: String,
8656 args: Value,
8657 output: String,
8658 is_error: bool,
8659 blocked_by_policy: bool,
8660 msg_results: Vec<ChatMessage>,
8661 latest_target_dir: Option<String>,
8662 plan_drafted_this_turn: bool,
8663 parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
8664}
8665
8666#[derive(Clone)]
8667struct CachedToolResult {
8668 tool_name: String,
8669}
8670
8671fn is_code_like_path(path: &str) -> bool {
8672 let ext = std::path::Path::new(path)
8673 .extension()
8674 .and_then(|e| e.to_str())
8675 .unwrap_or("")
8676 .to_ascii_lowercase();
8677 matches!(
8678 ext.as_str(),
8679 "rs" | "js"
8680 | "ts"
8681 | "tsx"
8682 | "jsx"
8683 | "py"
8684 | "go"
8685 | "java"
8686 | "c"
8687 | "cpp"
8688 | "cc"
8689 | "h"
8690 | "hpp"
8691 | "cs"
8692 | "swift"
8693 | "kt"
8694 | "kts"
8695 | "rb"
8696 | "php"
8697 )
8698}
8699
8700pub fn format_tool_display(name: &str, args: &Value) -> String {
8703 let get = |key: &str| {
8704 args.get(key)
8705 .and_then(|v| v.as_str())
8706 .unwrap_or("")
8707 .to_string()
8708 };
8709 match name {
8710 "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
8711 "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
8712 "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
8713 "describe_toolchain" => format!("describe toolchain {}", get("topic")),
8714 "inspect_host" => format!("inspect host {}", get("topic")),
8715 "write_file"
8716 | "read_file"
8717 | "edit_file"
8718 | "patch_hunk"
8719 | "inspect_lines"
8720 | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
8721 "grep_files" => format!(
8722 "grep_files pattern='{}' path='{}'",
8723 get("pattern"),
8724 get("path")
8725 ),
8726 "list_files" => format!("list_files `{}`", get("path")),
8727 "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
8728 _ => {
8729 let rep = format!("{} {:?}", name, args);
8731 if rep.len() > 100 {
8732 format!("{}... (truncated)", &rep[..100])
8733 } else {
8734 rep
8735 }
8736 }
8737 }
8738}
8739
8740pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
8743 let lower = command.to_ascii_lowercase();
8744 [
8745 "$env:path",
8746 "pathvariable",
8747 "pip --version",
8748 "pipx --version",
8749 "winget --version",
8750 "choco",
8751 "scoop",
8752 "get-childitem",
8753 "gci ",
8754 "where.exe",
8755 "where ",
8756 "cargo --version",
8757 "rustc --version",
8758 "git --version",
8759 "node --version",
8760 "npm --version",
8761 "pnpm --version",
8762 "python --version",
8763 "python3 --version",
8764 "deno --version",
8765 "go version",
8766 "dotnet --version",
8767 "uv --version",
8768 "netstat",
8769 "findstr",
8770 "get-nettcpconnection",
8771 "tcpconnection",
8772 "listening",
8773 "ss -",
8774 "ss ",
8775 "lsof",
8776 "tasklist",
8777 "ipconfig",
8778 "get-netipconfiguration",
8779 "get-netadapter",
8780 "route print",
8781 "ifconfig",
8782 "ip addr",
8783 "ip route",
8784 "resolv.conf",
8785 "get-service",
8786 "sc query",
8787 "systemctl",
8788 "service --status-all",
8789 "get-process",
8790 "working set",
8791 "ps -eo",
8792 "ps aux",
8793 "desktop",
8794 "downloads",
8795 "get-netfirewallprofile",
8796 "win32_powerplan",
8797 "win32_operatingsystem",
8798 "win32_processor",
8799 "wmic",
8800 "loadpercentage",
8801 "totalvisiblememory",
8802 "freephysicalmemory",
8803 "get-wmiobject",
8804 "get-ciminstance",
8805 "get-cpu",
8806 "processorname",
8807 "clockspeed",
8808 "top memory",
8809 "top cpu",
8810 "resource usage",
8811 "powercfg",
8812 "uptime",
8813 "lastbootuptime",
8814 "hklm:",
8816 "hkcu:",
8817 "hklm:\\",
8818 "hkcu:\\",
8819 "currentversion",
8820 "productname",
8821 "displayversion",
8822 "get-itemproperty",
8823 "get-itempropertyvalue",
8824 "get-windowsupdatelog",
8826 "windowsupdatelog",
8827 "microsoft.update.session",
8828 "createupdatesearcher",
8829 "wuauserv",
8830 "usoclient",
8831 "get-hotfix",
8832 "wu_",
8833 "get-mpcomputerstatus",
8835 "get-mppreference",
8836 "get-mpthreat",
8837 "start-mpscan",
8838 "win32_computersecurity",
8839 "softwarelicensingproduct",
8840 "enablelua",
8841 "get-netfirewallrule",
8842 "netfirewallprofile",
8843 "antivirus",
8844 "defenderstatus",
8845 "get-physicaldisk",
8847 "get-disk",
8848 "get-volume",
8849 "get-psdrive",
8850 "psdrive",
8851 "manage-bde",
8852 "bitlockervolume",
8853 "get-bitlockervolume",
8854 "get-smbencryptionstatus",
8855 "smbencryption",
8856 "get-netlanmanagerconnection",
8857 "lanmanager",
8858 "msstoragedriver_failurepredic",
8859 "win32_diskdrive",
8860 "smartstatus",
8861 "diskstatus",
8862 "get-counter",
8863 "intensity",
8864 "benchmark",
8865 "thrash",
8866 "get-item",
8867 "test-path",
8868 "gpresult",
8870 "applied gpo",
8871 "cert:\\",
8872 "cert:",
8873 "component based servicing",
8874 "componentstore",
8875 "get-computerinfo",
8876 "win32_computersystem",
8877 "win32_battery",
8879 "batterystaticdata",
8880 "batteryfullchargedcapacity",
8881 "batterystatus",
8882 "estimatedchargeremaining",
8883 "get-winevent",
8885 "eventid",
8886 "bugcheck",
8887 "kernelpower",
8888 "win32_ntlogevent",
8889 "filterhashtable",
8890 "get-scheduledtask",
8892 "get-scheduledtaskinfo",
8893 "schtasks",
8894 "taskscheduler",
8895 "get-acl",
8896 "icacls",
8897 "takeown",
8898 "event id 4624",
8899 "eventid 4624",
8900 "who logged in",
8901 "logon history",
8902 "login history",
8903 "get-smbshare",
8904 "net share",
8905 "mbps",
8906 "throughput",
8907 "whoami",
8908 "get-ciminstance win32",
8910 "get-wmiobject win32",
8911 "arp -",
8913 "arp -a",
8914 "tracert ",
8915 "traceroute ",
8916 "tracepath ",
8917 "get-dnsclientcache",
8918 "ipconfig /displaydns",
8919 "get-netroute",
8920 "get-netneighbor",
8921 "net view",
8922 "get-smbconnection",
8923 "get-smbmapping",
8924 "get-psdrive",
8925 "fdrespub",
8926 "fdphost",
8927 "ssdpsrv",
8928 "upnphost",
8929 "avahi-browse",
8930 "route print",
8931 "ip neigh",
8932 "get-pnpdevice -class audioendpoint",
8934 "get-pnpdevice -class media",
8935 "win32_sounddevice",
8936 "audiosrv",
8937 "audioendpointbuilder",
8938 "windows audio",
8939 "get-pnpdevice -class bluetooth",
8940 "bthserv",
8941 "bthavctpsvc",
8942 "btagservice",
8943 "bluetoothuserservice",
8944 "msiserver",
8945 "appxsvc",
8946 "clipsvc",
8947 "installservice",
8948 "desktopappinstaller",
8949 "microsoft.windowsstore",
8950 "get-appxpackage microsoft.desktopappinstaller",
8951 "get-appxpackage microsoft.windowsstore",
8952 "winget source",
8953 "winget --info",
8954 "onedrive",
8955 "onedrive.exe",
8956 "files on-demand",
8957 "known folder backup",
8958 "disablefilesyncngsc",
8959 "kfmsilentoptin",
8960 "kfmblockoptin",
8961 "get-process chrome",
8962 "get-process msedge",
8963 "get-process firefox",
8964 "get-process msedgewebview2",
8965 "google chrome",
8966 "microsoft edge",
8967 "mozilla firefox",
8968 "webview2",
8969 "msedgewebview2",
8970 "startmenuinternet",
8971 "urlassociations\\http\\userchoice",
8972 "urlassociations\\https\\userchoice",
8973 "software\\policies\\microsoft\\edge",
8974 "software\\policies\\google\\chrome",
8975 "get-winevent",
8976 "event id",
8977 "eventlog",
8978 "event viewer",
8979 "wevtutil",
8980 "cmdkey",
8981 "credential manager",
8982 "get-tpm",
8983 "confirm-securebootuefi",
8984 "win32_tpm",
8985 "dsregcmd",
8986 "webauthmanager",
8987 "web account manager",
8988 "tokenbroker",
8989 "token broker",
8990 "aad broker",
8991 "brokerplugin",
8992 "microsoft.aad.brokerplugin",
8993 "workplace join",
8994 "device registration",
8995 "secure boot",
8996 "get-aduser",
8998 "get-addomain",
8999 "get-adforest",
9000 "get-adgroup",
9001 "get-adcomputer",
9002 "activedirectory",
9003 "get-localuser",
9004 "get-localgroup",
9005 "get-localgroupmember",
9006 "net user",
9007 "net localgroup",
9008 "netsh winhttp show proxy",
9009 "get-itemproperty.*proxy",
9010 "get-netadapter",
9011 "netsh wlan show",
9012 "test-netconnection",
9013 "resolve-dnsname",
9014 "nslookup",
9015 "dig ",
9016 "gethostentry",
9017 "gethostaddresses",
9018 "getipaddresses",
9019 "[system.net.dns]",
9020 "net.dns]",
9021 "get-netfirewallrule",
9022 "docker ps",
9024 "docker info",
9025 "docker images",
9026 "docker container",
9027 "docker inspect",
9028 "docker volume",
9029 "docker system df",
9030 "docker compose ls",
9031 "wsl --list",
9032 "wsl -l",
9033 "wsl --status",
9034 "wsl --version",
9035 "wsl -d",
9036 "wsl df",
9037 "wsl du",
9038 "/mnt/c",
9039 "ssh -v",
9040 "get-service sshd",
9041 "get-service -name sshd",
9042 "cat ~/.ssh",
9043 "ls ~/.ssh",
9044 "ls -la ~/.ssh",
9045 "get-childitem env:",
9047 "dir env:",
9048 "printenv",
9049 "[environment]::getenvironmentvariable",
9050 "get-content.*hosts",
9051 "cat /etc/hosts",
9052 "type c:\\windows\\system32\\drivers\\etc\\hosts",
9053 "git config --global --list",
9054 "git config --list",
9055 "git config --global",
9056 "get-service mysql",
9058 "get-service postgresql",
9059 "get-service mongodb",
9060 "get-service redis",
9061 "get-service mssql",
9062 "get-service mariadb",
9063 "systemctl status postgresql",
9064 "systemctl status mysql",
9065 "systemctl status mongod",
9066 "systemctl status redis",
9067 "winget list",
9069 "get-package",
9070 "get-itempropert.*uninstall",
9071 "dpkg --get-selections",
9072 "rpm -qa",
9073 "brew list",
9074 "get-localuser",
9076 "get-localgroupmember",
9077 "net user",
9078 "query user",
9079 "net localgroup administrators",
9080 "auditpol /get",
9082 "auditpol",
9083 "get-smbshare",
9085 "get-smbserverconfiguration",
9086 "net share",
9087 "net use",
9088 "get-dnsclientserveraddress",
9090 "get-dnsclientdohserveraddress",
9091 "get-dnsclientglobalsetting",
9092 ]
9093 .iter()
9094 .any(|needle| lower.contains(needle))
9095 || lower.starts_with("host ")
9096}
9097
9098fn cap_output(text: &str, max_bytes: usize) -> String {
9101 cap_output_for_tool(text, max_bytes, "output")
9102}
9103
9104fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
9109 if text.len() <= max_bytes {
9110 return text.to_string();
9111 }
9112
9113 let scratch_path = write_output_to_scratch(text, tool_name);
9115
9116 let mut split_at = max_bytes;
9117 while !text.is_char_boundary(split_at) && split_at > 0 {
9118 split_at -= 1;
9119 }
9120
9121 let tail = match &scratch_path {
9122 Some(p) => format!(
9123 "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
9124 text.len(),
9125 text.lines().count(),
9126 p
9127 ),
9128 None => format!("\n... [output capped at {}B]", max_bytes),
9129 };
9130
9131 format!("{}{}", &text[..split_at], tail)
9132}
9133
9134fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
9137 let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
9138 if std::fs::create_dir_all(&scratch_dir).is_err() {
9139 return None;
9140 }
9141 let ts = std::time::SystemTime::now()
9142 .duration_since(std::time::UNIX_EPOCH)
9143 .map(|d| d.as_secs())
9144 .unwrap_or(0);
9145 let safe_name: String = tool_name
9147 .chars()
9148 .map(|c| {
9149 if c.is_alphanumeric() || c == '_' {
9150 c
9151 } else {
9152 '_'
9153 }
9154 })
9155 .collect();
9156 let filename = format!("{}_{}.txt", safe_name, ts);
9157 let abs_path = scratch_dir.join(&filename);
9158 if std::fs::write(&abs_path, text).is_err() {
9159 return None;
9160 }
9161 Some(format!(".hematite/scratch/{}", filename))
9162}
9163
9164#[derive(Default)]
9165struct PromptBudgetStats {
9166 summarized_tool_results: usize,
9167 collapsed_tool_results: usize,
9168 trimmed_chat_messages: usize,
9169 dropped_messages: usize,
9170}
9171
9172fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
9173 crate::agent::inference::estimate_message_batch_tokens(messages)
9174}
9175
9176fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
9177 let budget = compaction::SummaryCompressionBudget {
9178 max_chars,
9179 max_lines: 3,
9180 max_line_chars: max_chars.clamp(80, 240),
9181 };
9182 let compressed = compaction::compress_summary(text, budget).summary;
9183 if compressed.is_empty() {
9184 String::new()
9185 } else {
9186 compressed
9187 }
9188}
9189
9190fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
9191 let tool_name = message.name.as_deref().unwrap_or("tool");
9192 let body = summarize_prompt_blob(message.content.as_str(), 320);
9193 format!(
9194 "[Prompt-budget summary of prior `{}` result]\n{}",
9195 tool_name, body
9196 )
9197}
9198
9199fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
9200 let role = message.role.as_str();
9201 let body = summarize_prompt_blob(message.content.as_str(), 240);
9202 format!(
9203 "[Prompt-budget summary of earlier {} message]\n{}",
9204 role, body
9205 )
9206}
9207
9208fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
9209 if messages.len() > 1 && messages[1].role != "user" {
9210 messages.insert(1, ChatMessage::user("Continuing previous context..."));
9211 }
9212}
9213
9214fn enforce_prompt_budget(
9215 prompt_msgs: &mut Vec<ChatMessage>,
9216 context_length: usize,
9217) -> Option<String> {
9218 let target_tokens = ((context_length as f64) * 0.68) as usize;
9219 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9220 return None;
9221 }
9222
9223 let mut stats = PromptBudgetStats::default();
9224
9225 let mut tool_indices: Vec<usize> = prompt_msgs
9227 .iter()
9228 .enumerate()
9229 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
9230 .collect();
9231 for idx in tool_indices.iter().rev().copied() {
9232 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9233 break;
9234 }
9235 let original = prompt_msgs[idx].content.as_str().to_string();
9236 if original.len() > 1200 {
9237 prompt_msgs[idx].content =
9238 MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
9239 stats.summarized_tool_results += 1;
9240 }
9241 }
9242
9243 tool_indices = prompt_msgs
9245 .iter()
9246 .enumerate()
9247 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
9248 .collect();
9249 if tool_indices.len() > 2 {
9250 for idx in tool_indices
9251 .iter()
9252 .take(tool_indices.len().saturating_sub(2))
9253 .copied()
9254 {
9255 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9256 break;
9257 }
9258 prompt_msgs[idx].content = MessageContent::Text(
9259 "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
9260 );
9261 stats.collapsed_tool_results += 1;
9262 }
9263 }
9264
9265 let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9267 for idx in 1..prompt_msgs.len() {
9268 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9269 break;
9270 }
9271 if Some(idx) == last_user_idx {
9272 continue;
9273 }
9274 let role = prompt_msgs[idx].role.as_str();
9275 if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
9276 prompt_msgs[idx].content =
9277 MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
9278 stats.trimmed_chat_messages += 1;
9279 }
9280 }
9281
9282 let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9284 let mut idx = 1usize;
9285 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9286 if idx >= prompt_msgs.len() {
9287 break;
9288 }
9289
9290 let role = prompt_msgs[idx].role.as_str();
9291 if role == "user" || Some(idx) == preserve_last_user_idx {
9292 idx += 1;
9294 continue;
9295 }
9296
9297 prompt_msgs.remove(idx);
9299 stats.dropped_messages += 1;
9300 }
9301
9302 let mut idx = 1usize;
9304 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9305 if Some(idx) == preserve_last_user_idx {
9306 idx += 1;
9307 if idx >= prompt_msgs.len() {
9308 break;
9309 }
9310 continue;
9311 }
9312 if idx >= prompt_msgs.len() {
9313 break;
9314 }
9315 prompt_msgs.remove(idx);
9316 stats.dropped_messages += 1;
9317 }
9318
9319 normalize_prompt_start(prompt_msgs);
9320
9321 let new_tokens = estimate_prompt_tokens(prompt_msgs);
9322 if stats.summarized_tool_results == 0
9323 && stats.collapsed_tool_results == 0
9324 && stats.trimmed_chat_messages == 0
9325 && stats.dropped_messages == 0
9326 {
9327 return None;
9328 }
9329
9330 Some(format!(
9331 "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).",
9332 new_tokens,
9333 target_tokens,
9334 stats.summarized_tool_results,
9335 stats.collapsed_tool_results,
9336 stats.trimmed_chat_messages,
9337 stats.dropped_messages
9338 ))
9339}
9340
9341fn is_quick_tool_request(input: &str) -> bool {
9346 let lower = input.to_lowercase();
9347 if lower.contains("run_code") || lower.contains("run code") {
9349 return true;
9350 }
9351 let is_short = input.len() < 120;
9353 let compute_keywords = [
9354 "calculate",
9355 "compute",
9356 "execute",
9357 "run this",
9358 "test this",
9359 "what is ",
9360 "how much",
9361 "how many",
9362 "convert ",
9363 "print ",
9364 ];
9365 if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
9366 return true;
9367 }
9368 false
9369}
9370
9371fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
9372 let mut chunks = Vec::new();
9373 let mut current = String::new();
9374 let mut count = 0;
9375
9376 for ch in text.chars() {
9377 current.push(ch);
9378 if ch == ' ' || ch == '\n' {
9379 count += 1;
9380 if count >= words_per_chunk {
9381 chunks.push(current.clone());
9382 current.clear();
9383 count = 0;
9384 }
9385 }
9386 }
9387 if !current.is_empty() {
9388 chunks.push(current);
9389 }
9390 chunks
9391}
9392
9393fn repaired_plan_tool_args(
9394 tool_name: &str,
9395 args: &Value,
9396 task_file_exists: bool,
9397 fallback_target: Option<&str>,
9398 explicit_query: Option<&str>,
9399) -> Option<(Value, String)> {
9400 match tool_name {
9401 "read_file" | "inspect_lines" => {
9402 let has_path = args
9403 .as_object()
9404 .and_then(|map| map.get("path"))
9405 .and_then(|v| v.as_str())
9406 .map(|s| !s.trim().is_empty())
9407 .unwrap_or(false);
9408 if has_path {
9409 return None;
9410 }
9411
9412 let target = if task_file_exists {
9413 Some(".hematite/TASK.md")
9414 } else {
9415 fallback_target
9416 }?;
9417 let mut repaired = if args.is_object() {
9418 args.clone()
9419 } else {
9420 Value::Object(serde_json::Map::new())
9421 };
9422 let map = repaired.as_object_mut()?;
9423 map.insert("path".to_string(), Value::String(target.to_string()));
9424 Some((
9425 repaired,
9426 format!(
9427 "Recovered malformed `{}` call during current-plan execution by grounding it to `{}`.",
9428 tool_name, target
9429 ),
9430 ))
9431 }
9432 "research_web" => {
9433 let has_query = args
9434 .as_object()
9435 .and_then(|map| map.get("query"))
9436 .and_then(|v| v.as_str())
9437 .map(|s| !s.trim().is_empty())
9438 .unwrap_or(false);
9439 if has_query {
9440 return None;
9441 }
9442 let query = explicit_query?.trim();
9443 if query.is_empty() {
9444 return None;
9445 }
9446 let mut repaired = if args.is_object() {
9447 args.clone()
9448 } else {
9449 Value::Object(serde_json::Map::new())
9450 };
9451 let map = repaired.as_object_mut()?;
9452 map.insert("query".to_string(), Value::String(query.to_string()));
9453 Some((
9454 repaired,
9455 format!(
9456 "Recovered malformed `research_web` call during current-plan execution by restoring query `{}`.",
9457 query
9458 ),
9459 ))
9460 }
9461 _ => None,
9462 }
9463}
9464
9465fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
9466 if call.name != "read_file" {
9467 return None;
9468 }
9469 let mut args = call.arguments.clone();
9470 crate::agent::inference::normalize_tool_argument_value(&call.name, &mut args);
9471 let path = args.get("path").and_then(|v| v.as_str())?;
9472 Some(normalize_workspace_path(path))
9473}
9474
9475fn order_batch_reads_first(
9476 calls: Vec<crate::agent::inference::ToolCallResponse>,
9477) -> (
9478 Vec<crate::agent::inference::ToolCallResponse>,
9479 Option<String>,
9480) {
9481 let has_reads = calls.iter().any(|c| {
9482 matches!(
9483 c.function.name.as_str(),
9484 "read_file" | "inspect_lines" | "grep_files" | "list_files"
9485 )
9486 });
9487 let has_edits = calls.iter().any(|c| {
9488 matches!(
9489 c.function.name.as_str(),
9490 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9491 )
9492 });
9493 if has_reads && has_edits {
9494 let reads: Vec<_> = calls
9495 .into_iter()
9496 .filter(|c| {
9497 !matches!(
9498 c.function.name.as_str(),
9499 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9500 )
9501 })
9502 .collect();
9503 let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
9504 (reads, note)
9505 } else {
9506 (calls, None)
9507 }
9508}
9509
9510fn grep_output_is_high_fanout(output: &str) -> bool {
9511 let Some(summary) = output.lines().next() else {
9512 return false;
9513 };
9514 let hunk_count = summary
9515 .split(", ")
9516 .find_map(|part| {
9517 part.strip_suffix(" hunk(s)")
9518 .and_then(|value| value.parse::<usize>().ok())
9519 })
9520 .unwrap_or(0);
9521 let match_count = summary
9522 .split(' ')
9523 .next()
9524 .and_then(|value| value.parse::<usize>().ok())
9525 .unwrap_or(0);
9526 hunk_count >= 8 || match_count >= 12
9527}
9528
9529fn build_system_with_corrections(
9530 base: &str,
9531 hints: &[String],
9532 gpu: &Arc<GpuState>,
9533 git: &Arc<crate::agent::git_monitor::GitState>,
9534 config: &crate::agent::config::HematiteConfig,
9535) -> String {
9536 let mut system_msg = base.to_string();
9537
9538 system_msg.push_str("\n\n# Permission Mode\n");
9540 let mode_label = match config.mode {
9541 crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
9542 crate::agent::config::PermissionMode::Developer => "DEVELOPER",
9543 crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
9544 };
9545 system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
9546
9547 if config.mode == crate::agent::config::PermissionMode::ReadOnly {
9548 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");
9549 } else {
9550 system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
9551 }
9552
9553 let (used, total) = gpu.read();
9555 if total > 0 {
9556 system_msg.push_str("\n\n# Terminal Hardware Context\n");
9557 system_msg.push_str(&format!(
9558 "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
9559 gpu.gpu_name(),
9560 used as f64 / 1024.0,
9561 total as f64 / 1024.0,
9562 gpu.ratio() * 100.0
9563 ));
9564 system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
9565 }
9566
9567 system_msg.push_str("\n\n# Git Repository Context\n");
9569 let git_status_label = git.label();
9570 let git_url = git.url();
9571 system_msg.push_str(&format!(
9572 "REMOTE STATUS: {} | URL: {}\n",
9573 git_status_label, git_url
9574 ));
9575
9576 let root = crate::tools::file_ops::workspace_root();
9578 if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
9579 system_msg.push_str("\nGit status snapshot:\n");
9580 system_msg.push_str(&status_snapshot);
9581 system_msg.push_str("\n");
9582 }
9583
9584 if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
9585 system_msg.push_str("\nGit diff snapshot:\n");
9586 system_msg.push_str(&diff_snapshot);
9587 system_msg.push_str("\n");
9588 }
9589
9590 if git_status_label == "NONE" {
9591 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");
9592 } else if git_status_label == "BEHIND" {
9593 system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
9594 }
9595
9596 if hints.is_empty() {
9601 return system_msg;
9602 }
9603 system_msg.push_str("\n\n# Formatting Corrections\n");
9604 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");
9605 for hint in hints {
9606 system_msg.push_str(&format!("- {}\n", hint));
9607 }
9608 system_msg
9609}
9610
9611fn route_model<'a>(
9612 user_input: &str,
9613 fast_model: Option<&'a str>,
9614 think_model: Option<&'a str>,
9615) -> Option<&'a str> {
9616 let text = user_input.to_lowercase();
9617 let is_think = text.contains("refactor")
9618 || text.contains("rewrite")
9619 || text.contains("implement")
9620 || text.contains("create")
9621 || text.contains("fix")
9622 || text.contains("debug");
9623 let is_fast = text.contains("what")
9624 || text.contains("show")
9625 || text.contains("find")
9626 || text.contains("list")
9627 || text.contains("status");
9628
9629 if is_think && think_model.is_some() {
9630 return think_model;
9631 } else if is_fast && fast_model.is_some() {
9632 return fast_model;
9633 }
9634 None
9635}
9636
9637fn is_parallel_safe(name: &str) -> bool {
9638 let metadata = crate::agent::inference::tool_metadata_for_name(name);
9639 !metadata.mutates_workspace && !metadata.external_surface
9640}
9641
9642fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
9643 if docs_only_mode {
9644 return true;
9645 }
9646
9647 let lower = query.to_ascii_lowercase();
9648 [
9649 "what did we decide",
9650 "why did we decide",
9651 "what did we say",
9652 "what did we do",
9653 "earlier today",
9654 "yesterday",
9655 "last week",
9656 "last month",
9657 "earlier",
9658 "remember",
9659 "session",
9660 "import",
9661 ]
9662 .iter()
9663 .any(|needle| lower.contains(needle))
9664 || lower
9665 .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
9666 .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
9667}
9668
9669#[cfg(test)]
9670mod tests {
9671 use super::*;
9672
9673 #[test]
9674 fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
9675 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."}"#;
9676 let class = classify_runtime_failure(detail);
9677 assert_eq!(class, RuntimeFailureClass::ContextWindow);
9678 assert_eq!(class.tag(), "context_window");
9679 assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
9680 }
9681
9682 #[test]
9683 fn formatted_runtime_failure_is_not_wrapped_twice() {
9684 let detail =
9685 "[failure:provider_degraded] Retry once automatically, then narrow the turn or restart LM Studio if it persists. Detail: LMS unreachable: Request failed";
9686 let formatted = format_runtime_failure(RuntimeFailureClass::ProviderDegraded, detail);
9687 assert_eq!(formatted, detail);
9688 assert_eq!(formatted.matches("[failure:provider_degraded]").count(), 1);
9689 }
9690
9691 #[test]
9692 fn explicit_search_detection_requires_search_language() {
9693 assert!(is_explicit_web_search_request("search for ocean bennett"));
9694 assert!(is_explicit_web_search_request("google ocean bennett"));
9695 assert!(is_explicit_web_search_request("look up ocean bennett"));
9696 assert!(!is_explicit_web_search_request("who is ocean bennett"));
9697 }
9698
9699 #[test]
9700 fn explicit_search_query_extracts_leading_search_clause_from_mixed_request() {
9701 assert_eq!(
9702 extract_explicit_web_search_query(
9703 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it"
9704 ),
9705 Some("uefn toolbelt".to_string())
9706 );
9707 }
9708
9709 #[test]
9710 fn auto_research_handover_is_turn_scoped_only() {
9711 assert!(should_use_turn_scoped_investigation_mode(
9712 WorkflowMode::Auto,
9713 QueryIntentClass::Research
9714 ));
9715 assert!(!should_use_turn_scoped_investigation_mode(
9716 WorkflowMode::Ask,
9717 QueryIntentClass::Research
9718 ));
9719 assert!(!should_use_turn_scoped_investigation_mode(
9720 WorkflowMode::Auto,
9721 QueryIntentClass::RepoArchitecture
9722 ));
9723 }
9724
9725 #[test]
9726 fn research_provider_fallback_mentions_direct_search_results() {
9727 let fallback = build_research_provider_fallback(
9728 "[Source: SearXNG]\n\n### 1. [Ocean Bennett](https://example.com)\nBio",
9729 );
9730 assert!(fallback.contains("Local web search succeeded"));
9731 assert!(fallback.contains("[Source: SearXNG]"));
9732 assert!(fallback.contains("Ocean Bennett"));
9733 }
9734
9735 #[test]
9736 fn runtime_failure_maps_to_provider_and_checkpoint_state() {
9737 assert_eq!(
9738 provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9739 Some(ProviderRuntimeState::ContextWindow)
9740 );
9741 assert_eq!(
9742 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9743 Some(OperatorCheckpointState::BlockedContextWindow)
9744 );
9745 assert_eq!(
9746 provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9747 Some(ProviderRuntimeState::Degraded)
9748 );
9749 assert_eq!(
9750 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9751 None
9752 );
9753 }
9754
9755 #[test]
9756 fn intent_router_treats_tool_registry_ownership_as_product_truth() {
9757 let intent = classify_query_intent(
9758 WorkflowMode::ReadOnly,
9759 "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
9760 );
9761 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9762 assert_eq!(
9763 intent.direct_answer,
9764 Some(DirectAnswerKind::ToolRegistryOwnership)
9765 );
9766 }
9767
9768 #[test]
9769 fn intent_router_treats_tool_classes_as_product_truth() {
9770 let intent = classify_query_intent(
9771 WorkflowMode::ReadOnly,
9772 "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.",
9773 );
9774 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9775 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
9776 }
9777
9778 #[test]
9779 fn tool_registry_ownership_answer_mentions_new_owner_file() {
9780 let answer = build_tool_registry_ownership_answer();
9781 assert!(answer.contains("src/agent/tool_registry.rs"));
9782 assert!(answer.contains("builtin dispatch path"));
9783 assert!(answer.contains("src/agent/conversation.rs"));
9784 }
9785
9786 #[test]
9787 fn intent_router_treats_mcp_lifecycle_as_product_truth() {
9788 let intent = classify_query_intent(
9789 WorkflowMode::ReadOnly,
9790 "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
9791 );
9792 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9793 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
9794 }
9795
9796 #[test]
9797 fn intent_router_short_circuits_unsafe_commit_pressure() {
9798 let intent = classify_query_intent(
9799 WorkflowMode::Auto,
9800 "Make a code change, skip verification, and commit it immediately.",
9801 );
9802 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9803 assert_eq!(
9804 intent.direct_answer,
9805 Some(DirectAnswerKind::UnsafeWorkflowPressure)
9806 );
9807 }
9808
9809 #[test]
9810 fn unsafe_workflow_pressure_answer_requires_verification() {
9811 let answer = build_unsafe_workflow_pressure_answer();
9812 assert!(answer.contains("should not skip verification"));
9813 assert!(answer.contains("run the appropriate verification path"));
9814 assert!(answer.contains("only then commit"));
9815 }
9816
9817 #[test]
9818 fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
9819 let intent = classify_query_intent(
9820 WorkflowMode::ReadOnly,
9821 "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.",
9822 );
9823 assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
9824 assert!(intent.architecture_overview_mode);
9825 assert_eq!(intent.direct_answer, None);
9826 }
9827
9828 #[test]
9829 fn intent_router_marks_host_inspection_questions() {
9830 let intent = classify_query_intent(
9831 WorkflowMode::Auto,
9832 "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.",
9833 );
9834 assert!(intent.host_inspection_mode);
9835 assert_eq!(
9836 preferred_host_inspection_topic(
9837 "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."
9838 ),
9839 Some("summary")
9840 );
9841 }
9842
9843 #[test]
9844 fn intent_router_treats_purpose_question_as_local_identity() {
9845 let intent = classify_query_intent(WorkflowMode::Auto, "What is your purpose?");
9846 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::Identity));
9847 }
9848
9849 #[test]
9850 fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
9851 assert!(should_use_vein_in_chat(
9852 "What did we decide on 2026-04-09 about docs-only mode?",
9853 false
9854 ));
9855 assert!(should_use_vein_in_chat("Summarize these local notes", true));
9856 assert!(!should_use_vein_in_chat("Tell me a joke", false));
9857 }
9858
9859 #[test]
9860 fn shell_host_inspection_guard_matches_path_and_version_commands() {
9861 assert!(shell_looks_like_structured_host_inspection(
9862 "$env:PATH -split ';'"
9863 ));
9864 assert!(shell_looks_like_structured_host_inspection(
9865 "cargo --version"
9866 ));
9867 assert!(shell_looks_like_structured_host_inspection(
9868 "Get-NetTCPConnection -LocalPort 3000"
9869 ));
9870 assert!(shell_looks_like_structured_host_inspection(
9871 "netstat -ano | findstr :3000"
9872 ));
9873 assert!(shell_looks_like_structured_host_inspection(
9874 "Get-Process | Sort-Object WS -Descending"
9875 ));
9876 assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
9877 assert!(shell_looks_like_structured_host_inspection("Get-Service"));
9878 assert!(shell_looks_like_structured_host_inspection(
9879 "winget --version"
9880 ));
9881 assert!(shell_looks_like_structured_host_inspection(
9882 "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
9883 ));
9884 assert!(shell_looks_like_structured_host_inspection(
9885 "Get-NetNeighbor -AddressFamily IPv4"
9886 ));
9887 assert!(shell_looks_like_structured_host_inspection(
9888 "Get-SmbConnection"
9889 ));
9890 assert!(shell_looks_like_structured_host_inspection(
9891 "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
9892 ));
9893 assert!(shell_looks_like_structured_host_inspection(
9894 "Get-PnpDevice -Class AudioEndpoint"
9895 ));
9896 assert!(shell_looks_like_structured_host_inspection(
9897 "Get-CimInstance Win32_SoundDevice"
9898 ));
9899 assert!(shell_looks_like_structured_host_inspection(
9900 "Get-PnpDevice -Class Bluetooth"
9901 ));
9902 assert!(shell_looks_like_structured_host_inspection(
9903 "Get-Service bthserv,BthAvctpSvc,BTAGService"
9904 ));
9905 assert!(shell_looks_like_structured_host_inspection(
9906 "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
9907 ));
9908 assert!(shell_looks_like_structured_host_inspection(
9909 "Get-AppxPackage Microsoft.DesktopAppInstaller"
9910 ));
9911 assert!(shell_looks_like_structured_host_inspection(
9912 "winget source list"
9913 ));
9914 assert!(shell_looks_like_structured_host_inspection(
9915 "Get-Process OneDrive"
9916 ));
9917 assert!(shell_looks_like_structured_host_inspection(
9918 "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
9919 ));
9920 assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
9921 assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
9922 assert!(shell_looks_like_structured_host_inspection(
9923 "Confirm-SecureBootUEFI"
9924 ));
9925 assert!(shell_looks_like_structured_host_inspection(
9926 "dsregcmd /status"
9927 ));
9928 assert!(shell_looks_like_structured_host_inspection(
9929 "Get-Service TokenBroker,wlidsvc,OneAuth"
9930 ));
9931 assert!(shell_looks_like_structured_host_inspection(
9932 "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
9933 ));
9934 assert!(shell_looks_like_structured_host_inspection(
9935 "host github.com"
9936 ));
9937 assert!(shell_looks_like_structured_host_inspection(
9938 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
9939 ));
9940 }
9941
9942 #[test]
9943 fn dns_shell_target_extraction_handles_common_lookup_forms() {
9944 assert_eq!(
9945 extract_dns_lookup_target_from_shell("host github.com").as_deref(),
9946 Some("github.com")
9947 );
9948 assert_eq!(
9949 extract_dns_lookup_target_from_shell(
9950 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
9951 )
9952 .as_deref(),
9953 Some("github.com")
9954 );
9955 assert_eq!(
9956 extract_dns_lookup_target_from_shell(
9957 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
9958 )
9959 .as_deref(),
9960 Some("github.com")
9961 );
9962 }
9963
9964 #[test]
9965 fn dns_prompt_target_extraction_handles_plain_english_questions() {
9966 assert_eq!(
9967 extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
9968 Some("github.com")
9969 );
9970 assert_eq!(
9971 extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
9972 Some("google.com")
9973 );
9974 }
9975
9976 #[test]
9977 fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
9978 assert_eq!(
9979 extract_dns_record_type_from_text("Show me the A record for github.com"),
9980 Some("A")
9981 );
9982 assert_eq!(
9983 extract_dns_record_type_from_text("What is the IP address of google.com"),
9984 Some("A")
9985 );
9986 assert_eq!(
9987 extract_dns_record_type_from_text("Resolve the MX record for example.com"),
9988 Some("MX")
9989 );
9990 assert_eq!(
9991 extract_dns_record_type_from_shell(
9992 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
9993 ),
9994 Some("A")
9995 );
9996 assert_eq!(
9997 extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
9998 Some("MX")
9999 );
10000 }
10001
10002 #[test]
10003 fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
10004 let mut tool_name = "inspect_host".to_string();
10005 let mut args = serde_json::json!({
10006 "topic": "dns_lookup"
10007 });
10008 rewrite_host_tool_call(
10009 &mut tool_name,
10010 &mut args,
10011 Some("Show me the A record for github.com"),
10012 );
10013 assert_eq!(tool_name, "inspect_host");
10014 assert_eq!(
10015 args.get("name").and_then(|value| value.as_str()),
10016 Some("github.com")
10017 );
10018 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10019 }
10020
10021 #[test]
10022 fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
10023 let args =
10024 host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
10025 assert_eq!(
10026 args.get("name").and_then(|value| value.as_str()),
10027 Some("google.com")
10028 );
10029 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10030 }
10031
10032 #[test]
10033 fn host_inspection_args_from_prompt_populates_event_query_fields() {
10034 let args = host_inspection_args_from_prompt(
10035 "event_query",
10036 "Show me all System errors from the Event Log that occurred in the last 4 hours.",
10037 );
10038 assert_eq!(
10039 args.get("log").and_then(|value| value.as_str()),
10040 Some("System")
10041 );
10042 assert_eq!(
10043 args.get("level").and_then(|value| value.as_str()),
10044 Some("Error")
10045 );
10046 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10047 }
10048
10049 #[test]
10050 fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
10051 let mut tool_name = "inspect_host".to_string();
10052 let mut args = serde_json::json!({
10053 "topic": "event_query"
10054 });
10055 rewrite_host_tool_call(
10056 &mut tool_name,
10057 &mut args,
10058 Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
10059 );
10060 assert_eq!(tool_name, "inspect_host");
10061 assert_eq!(
10062 args.get("log").and_then(|value| value.as_str()),
10063 Some("System")
10064 );
10065 assert_eq!(
10066 args.get("level").and_then(|value| value.as_str()),
10067 Some("Error")
10068 );
10069 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10070 }
10071
10072 #[test]
10073 fn intent_router_picks_ports_for_listening_port_questions() {
10074 assert_eq!(
10075 preferred_host_inspection_topic(
10076 "Show me what is listening on port 3000 and whether anything unexpected is exposed."
10077 ),
10078 Some("ports")
10079 );
10080 }
10081
10082 #[test]
10083 fn intent_router_picks_processes_for_host_process_questions() {
10084 assert_eq!(
10085 preferred_host_inspection_topic(
10086 "Show me what processes are using the most RAM right now."
10087 ),
10088 Some("processes")
10089 );
10090 }
10091
10092 #[test]
10093 fn intent_router_picks_network_for_adapter_questions() {
10094 assert_eq!(
10095 preferred_host_inspection_topic(
10096 "Show me my active network adapters, IP addresses, gateways, and DNS servers."
10097 ),
10098 Some("network")
10099 );
10100 }
10101
10102 #[test]
10103 fn intent_router_picks_services_for_service_questions() {
10104 assert_eq!(
10105 preferred_host_inspection_topic(
10106 "Show me the running services and startup types that matter for a normal dev machine."
10107 ),
10108 Some("services")
10109 );
10110 }
10111
10112 #[test]
10113 fn intent_router_picks_env_doctor_for_package_manager_questions() {
10114 assert_eq!(
10115 preferred_host_inspection_topic(
10116 "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
10117 ),
10118 Some("env_doctor")
10119 );
10120 }
10121
10122 #[test]
10123 fn intent_router_picks_fix_plan_for_host_remediation_questions() {
10124 assert_eq!(
10125 preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
10126 Some("fix_plan")
10127 );
10128 assert_eq!(
10129 preferred_host_inspection_topic(
10130 "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
10131 ),
10132 Some("fix_plan")
10133 );
10134 }
10135
10136 #[test]
10137 fn intent_router_picks_audio_for_sound_and_microphone_questions() {
10138 assert_eq!(
10139 preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
10140 Some("audio")
10141 );
10142 assert_eq!(
10143 preferred_host_inspection_topic(
10144 "Check my microphone and playback devices because Windows Audio seems broken."
10145 ),
10146 Some("audio")
10147 );
10148 }
10149
10150 #[test]
10151 fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
10152 assert_eq!(
10153 preferred_host_inspection_topic(
10154 "Why won't this Bluetooth headset pair and stay connected?"
10155 ),
10156 Some("bluetooth")
10157 );
10158 assert_eq!(
10159 preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
10160 Some("bluetooth")
10161 );
10162 }
10163
10164 #[test]
10165 fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
10166 let mut args = serde_json::json!({
10167 "topic": "fix_plan"
10168 });
10169
10170 fill_missing_fix_plan_issue(
10171 "inspect_host",
10172 &mut args,
10173 Some("/think\nHow do I fix cargo not found on this machine?"),
10174 );
10175
10176 assert_eq!(
10177 args.get("issue").and_then(|value| value.as_str()),
10178 Some("How do I fix cargo not found on this machine?")
10179 );
10180 }
10181
10182 #[test]
10183 fn shell_fix_question_rewrites_to_fix_plan() {
10184 let args = serde_json::json!({
10185 "command": "where cargo"
10186 });
10187
10188 assert!(should_rewrite_shell_to_fix_plan(
10189 "shell",
10190 &args,
10191 Some("How do I fix cargo not found on this machine?")
10192 ));
10193 }
10194
10195 #[test]
10196 fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
10197 let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
10198 let shell_key = normalized_tool_call_key_for_dedupe(
10199 "shell",
10200 r#"{"command":"where cargo"}"#,
10201 false,
10202 latest_user_prompt,
10203 );
10204 let fix_plan_key = normalized_tool_call_key_for_dedupe(
10205 "inspect_host",
10206 r#"{"topic":"fix_plan"}"#,
10207 false,
10208 latest_user_prompt,
10209 );
10210
10211 assert_eq!(shell_key, fix_plan_key);
10212 }
10213
10214 #[test]
10215 fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
10216 let (tool_name, args) = normalized_tool_call_for_execution(
10217 "shell",
10218 &serde_json::json!({"command":"pwsh ./clean.ps1 -Deep -PruneDist"}),
10219 false,
10220 Some("Run my cleanup scripts."),
10221 );
10222
10223 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10224 assert_eq!(
10225 args.get("workflow").and_then(|value| value.as_str()),
10226 Some("clean")
10227 );
10228 assert_eq!(
10229 args.get("deep").and_then(|value| value.as_bool()),
10230 Some(true)
10231 );
10232 assert_eq!(
10233 args.get("prune_dist").and_then(|value| value.as_bool()),
10234 Some(true)
10235 );
10236 }
10237
10238 #[test]
10239 fn shell_release_script_rewrites_to_maintainer_workflow() {
10240 let (tool_name, args) = normalized_tool_call_for_execution(
10241 "shell",
10242 &serde_json::json!({"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}),
10243 false,
10244 Some("Run the release flow."),
10245 );
10246
10247 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10248 assert_eq!(
10249 args.get("workflow").and_then(|value| value.as_str()),
10250 Some("release")
10251 );
10252 assert_eq!(
10253 args.get("version").and_then(|value| value.as_str()),
10254 Some("0.4.5")
10255 );
10256 assert_eq!(
10257 args.get("push").and_then(|value| value.as_bool()),
10258 Some(true)
10259 );
10260 }
10261
10262 #[test]
10263 fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
10264 let (tool_name, args) = normalized_tool_call_for_execution(
10265 "shell",
10266 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10267 false,
10268 Some("Run the deep cleanup and prune old dist artifacts."),
10269 );
10270
10271 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10272 assert_eq!(
10273 args.get("workflow").and_then(|value| value.as_str()),
10274 Some("clean")
10275 );
10276 assert_eq!(
10277 args.get("deep").and_then(|value| value.as_bool()),
10278 Some(true)
10279 );
10280 assert_eq!(
10281 args.get("prune_dist").and_then(|value| value.as_bool()),
10282 Some(true)
10283 );
10284 }
10285
10286 #[test]
10287 fn shell_cargo_test_rewrites_to_workspace_workflow() {
10288 let (tool_name, args) = normalized_tool_call_for_execution(
10289 "shell",
10290 &serde_json::json!({"command":"cargo test"}),
10291 false,
10292 Some("Run cargo test in this project."),
10293 );
10294
10295 assert_eq!(tool_name, "run_workspace_workflow");
10296 assert_eq!(
10297 args.get("workflow").and_then(|value| value.as_str()),
10298 Some("command")
10299 );
10300 assert_eq!(
10301 args.get("command").and_then(|value| value.as_str()),
10302 Some("cargo test")
10303 );
10304 }
10305
10306 #[test]
10307 fn current_plan_execution_request_accepts_saved_plan_command() {
10308 assert!(is_current_plan_execution_request("/implement-plan"));
10309 assert!(is_current_plan_execution_request(
10310 "Implement the current plan."
10311 ));
10312 }
10313
10314 #[test]
10315 fn architect_operator_note_points_to_execute_path() {
10316 let plan = crate::tools::plan::PlanHandoff {
10317 goal: "Tighten startup workflow guidance".into(),
10318 target_files: vec!["src/runtime.rs".into()],
10319 ordered_steps: vec!["Update the startup banner".into()],
10320 verification: "cargo check --tests".into(),
10321 risks: vec![],
10322 open_questions: vec![],
10323 };
10324 let note = architect_handoff_operator_note(&plan);
10325 assert!(note.contains("`.hematite/PLAN.md`"));
10326 assert!(note.contains("/implement-plan"));
10327 assert!(note.contains("/code implement the current plan"));
10328 }
10329
10330 #[test]
10331 fn sovereign_scaffold_handoff_carries_explicit_research_step() {
10332 let mut targets = std::collections::BTreeSet::new();
10333 targets.insert("index.html".to_string());
10334 let plan = build_sovereign_scaffold_handoff(
10335 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it",
10336 &targets,
10337 );
10338
10339 assert!(plan
10340 .ordered_steps
10341 .iter()
10342 .any(|step| step.contains("research_web")));
10343 assert!(plan
10344 .ordered_steps
10345 .iter()
10346 .any(|step| step.contains("uefn toolbelt")));
10347 }
10348
10349 #[test]
10350 fn single_file_html_sovereign_targets_only_index() {
10351 let targets = default_sovereign_scaffold_targets(
10352 "google uefn toolbelt then make a folder on my desktop called yourtask and inside it create a single index.html that explains what you found",
10353 );
10354
10355 assert!(targets.contains("index.html"));
10356 assert!(!targets.contains("style.css"));
10357 assert!(!targets.contains("script.js"));
10358 }
10359
10360 #[test]
10361 fn single_file_html_handoff_verification_mentions_self_contained_index() {
10362 let mut targets = std::collections::BTreeSet::new();
10363 targets.insert("index.html".to_string());
10364 let plan = build_sovereign_scaffold_handoff(
10365 "google uefn toolbelt then make a folder on my desktop called yourtask and inside it create a single index.html that explains what you found",
10366 &targets,
10367 );
10368
10369 assert!(plan.verification.contains("index.html"));
10370 assert!(plan.verification.contains("self-contained"));
10371 assert!(plan
10372 .ordered_steps
10373 .iter()
10374 .any(|step| step.contains("single `index.html` file")));
10375 }
10376
10377 #[test]
10378 fn plan_handoff_mentions_tool_detects_research_steps() {
10379 let plan = crate::tools::plan::PlanHandoff {
10380 goal: "Build the site".into(),
10381 target_files: vec!["index.html".into()],
10382 ordered_steps: vec!["Use `research_web` first to gather context.".into()],
10383 verification: "verify_build(action: \"build\")".into(),
10384 risks: vec![],
10385 open_questions: vec![],
10386 };
10387
10388 assert!(plan_handoff_mentions_tool(&plan, "research_web"));
10389 assert!(!plan_handoff_mentions_tool(&plan, "fetch_docs"));
10390 }
10391
10392 #[test]
10393 fn parse_task_checklist_progress_counts_checked_items() {
10394 let progress = parse_task_checklist_progress(
10395 r#"
10396- [x] Build the landing page shell
10397- [ ] Wire the responsive nav
10398* [X] Add hero section copy
10399Plain paragraph
10400"#,
10401 );
10402
10403 assert_eq!(progress.total, 3);
10404 assert_eq!(progress.completed, 2);
10405 assert_eq!(progress.remaining, 1);
10406 assert!(progress.has_open_items());
10407 }
10408
10409 #[test]
10410 fn merge_plan_allowed_paths_includes_hematite_sidecars() {
10411 let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
10412
10413 assert!(allowed.contains(&normalize_workspace_path("src/main.rs")));
10414 assert!(allowed
10415 .iter()
10416 .any(|path| path.ends_with("/.hematite/task.md")));
10417 assert!(allowed
10418 .iter()
10419 .any(|path| path.ends_with("/.hematite/plan.md")));
10420 }
10421
10422 #[test]
10423 fn repaired_plan_tool_args_recovers_empty_read_to_task_ledger() {
10424 let args = serde_json::json!({});
10425 let (repaired, note) =
10426 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10427
10428 assert_eq!(
10429 repaired.get("path").and_then(|v| v.as_str()),
10430 Some(".hematite/TASK.md")
10431 );
10432 assert!(note.contains(".hematite/TASK.md"));
10433 }
10434
10435 #[test]
10436 fn repaired_plan_tool_args_recovers_empty_research_query() {
10437 let args = serde_json::json!({});
10438 let (repaired, note) = repaired_plan_tool_args(
10439 "research_web",
10440 &args,
10441 true,
10442 Some("index.html"),
10443 Some("uefn toolbelt"),
10444 )
10445 .unwrap();
10446
10447 assert_eq!(
10448 repaired.get("query").and_then(|v| v.as_str()),
10449 Some("uefn toolbelt")
10450 );
10451 assert!(note.contains("uefn toolbelt"));
10452 }
10453
10454 #[test]
10455 fn repaired_plan_tool_args_recovers_non_object_read_call() {
10456 let args = serde_json::json!("");
10457 let (repaired, _) =
10458 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10459
10460 assert_eq!(
10461 repaired.get("path").and_then(|v| v.as_str()),
10462 Some(".hematite/TASK.md")
10463 );
10464 }
10465
10466 #[test]
10467 fn force_plan_mutation_prompt_names_target_files() {
10468 let prompt = build_force_plan_mutation_prompt(
10469 TaskChecklistProgress {
10470 total: 5,
10471 completed: 0,
10472 remaining: 5,
10473 },
10474 &["index.html".to_string()],
10475 );
10476
10477 assert!(prompt.contains(".hematite/TASK.md"));
10478 assert!(prompt.contains("`index.html`"));
10479 assert!(prompt.contains("Do not summarize"));
10480 }
10481
10482 #[test]
10483 fn current_plan_scope_recovery_prompt_names_saved_targets() {
10484 let prompt = build_current_plan_scope_recovery_prompt(&["index.html".to_string()]);
10485
10486 assert!(prompt.contains("`index.html`"));
10487 assert!(prompt.contains(".hematite/TASK.md"));
10488 assert!(prompt.contains("Do not branch into unrelated files"));
10489 }
10490
10491 #[test]
10492 fn task_ledger_closeout_prompt_demands_checklist_update() {
10493 let prompt = build_task_ledger_closeout_prompt(
10494 TaskChecklistProgress {
10495 total: 5,
10496 completed: 0,
10497 remaining: 5,
10498 },
10499 &["index.html".to_string()],
10500 );
10501
10502 assert!(prompt.contains(".hematite/TASK.md"));
10503 assert!(prompt.contains("`index.html`"));
10504 assert!(prompt.contains("Do not summarize"));
10505 assert!(prompt.contains("`[x]`"));
10506 }
10507
10508 #[test]
10509 fn suppresses_recoverable_blocked_tool_result_only_when_redirect_exists() {
10510 assert!(should_suppress_recoverable_tool_result(true, true));
10511 assert!(!should_suppress_recoverable_tool_result(true, false));
10512 assert!(!should_suppress_recoverable_tool_result(false, true));
10513 }
10514
10515 #[test]
10516 fn sovereign_closeout_detects_materialized_targets() {
10517 let temp = tempfile::tempdir().unwrap();
10518 let previous = std::env::current_dir().unwrap();
10519 std::env::set_current_dir(temp.path()).unwrap();
10520 std::fs::write("index.html", "<html>ok</html>").unwrap();
10521
10522 assert!(target_files_materialized(&["index.html".to_string()]));
10523
10524 std::env::set_current_dir(previous).unwrap();
10525 }
10526
10527 #[test]
10528 fn deterministic_sovereign_closeout_returns_summary_when_targets_exist() {
10529 let temp = tempfile::tempdir().unwrap();
10530 let previous = std::env::current_dir().unwrap();
10531 std::env::set_current_dir(temp.path()).unwrap();
10532 std::fs::create_dir_all(".hematite").unwrap();
10533 std::fs::write("index.html", "<html>ok</html>").unwrap();
10534 std::fs::write(".hematite/TASK.md", "# Task Ledger\n\n- [ ] Build index\n").unwrap();
10535 std::fs::write(".hematite/WALKTHROUGH.md", "").unwrap();
10536
10537 let plan = crate::tools::plan::PlanHandoff {
10538 goal: "Continue the sovereign scaffold task in this new project root".to_string(),
10539 target_files: vec!["index.html".to_string()],
10540 ordered_steps: vec!["Build index".to_string()],
10541 verification: "Open index.html".to_string(),
10542 risks: vec![],
10543 open_questions: vec![],
10544 };
10545
10546 let summary = maybe_deterministic_sovereign_closeout(Some(&plan), true).unwrap();
10547 let task = std::fs::read_to_string(".hematite/TASK.md").unwrap();
10548
10549 std::env::set_current_dir(previous).unwrap();
10550
10551 assert!(summary.contains("Sovereign Scaffold Task Complete"));
10552 assert!(task.contains("- [x] Build index"));
10553 }
10554
10555 #[test]
10556 fn continue_plan_execution_requires_progress_and_open_items() {
10557 let mut mutated = std::collections::BTreeSet::new();
10558 mutated.insert("index.html".to_string());
10559
10560 assert!(should_continue_plan_execution(
10561 1,
10562 Some(TaskChecklistProgress {
10563 total: 3,
10564 completed: 1,
10565 remaining: 2,
10566 }),
10567 Some(TaskChecklistProgress {
10568 total: 3,
10569 completed: 2,
10570 remaining: 1,
10571 }),
10572 &mutated,
10573 ));
10574
10575 assert!(!should_continue_plan_execution(
10576 1,
10577 Some(TaskChecklistProgress {
10578 total: 3,
10579 completed: 2,
10580 remaining: 1,
10581 }),
10582 Some(TaskChecklistProgress {
10583 total: 3,
10584 completed: 2,
10585 remaining: 1,
10586 }),
10587 &std::collections::BTreeSet::new(),
10588 ));
10589
10590 assert!(!should_continue_plan_execution(
10591 6,
10592 Some(TaskChecklistProgress {
10593 total: 3,
10594 completed: 2,
10595 remaining: 1,
10596 }),
10597 Some(TaskChecklistProgress {
10598 total: 3,
10599 completed: 3,
10600 remaining: 0,
10601 }),
10602 &mutated,
10603 ));
10604 }
10605
10606 #[test]
10607 fn website_validation_runs_for_website_contract_frontend_paths() {
10608 let contract = crate::agent::workspace_profile::RuntimeContract {
10609 loop_family: "website".to_string(),
10610 app_kind: "website".to_string(),
10611 framework_hint: Some("vite".to_string()),
10612 preferred_workflows: vec!["website_validate".to_string()],
10613 delivery_phases: vec!["design".to_string(), "validate".to_string()],
10614 verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
10615 quality_gates: vec!["critical routes return HTTP 200".to_string()],
10616 local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
10617 route_hints: vec!["/".to_string()],
10618 };
10619 let mutated = std::collections::BTreeSet::from([
10620 "src/pages/index.tsx".to_string(),
10621 "public/app.css".to_string(),
10622 ]);
10623 assert!(should_run_website_validation(Some(&contract), &mutated));
10624 }
10625
10626 #[test]
10627 fn website_validation_skips_non_website_contracts() {
10628 let contract = crate::agent::workspace_profile::RuntimeContract {
10629 loop_family: "service".to_string(),
10630 app_kind: "node-service".to_string(),
10631 framework_hint: Some("express".to_string()),
10632 preferred_workflows: vec!["build".to_string()],
10633 delivery_phases: vec!["define boundary".to_string()],
10634 verification_workflows: vec!["build".to_string()],
10635 quality_gates: vec!["build stays green".to_string()],
10636 local_url_hint: None,
10637 route_hints: Vec::new(),
10638 };
10639 let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
10640 assert!(!should_run_website_validation(Some(&contract), &mutated));
10641 assert!(!should_run_website_validation(None, &mutated));
10642 }
10643
10644 #[test]
10645 fn repeat_guard_exempts_structured_website_validation() {
10646 assert!(is_repeat_guard_exempt_tool_call(
10647 "run_workspace_workflow",
10648 &serde_json::json!({ "workflow": "website_validate" }),
10649 ));
10650 assert!(!is_repeat_guard_exempt_tool_call(
10651 "run_workspace_workflow",
10652 &serde_json::json!({ "workflow": "build" }),
10653 ));
10654 }
10655
10656 #[test]
10657 fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
10658 let (tool_name, args) = normalized_tool_call_for_execution(
10659 "shell",
10660 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10661 false,
10662 Some("Run the tests in this project."),
10663 );
10664
10665 assert_eq!(tool_name, "run_workspace_workflow");
10666 assert_eq!(
10667 args.get("workflow").and_then(|value| value.as_str()),
10668 Some("test")
10669 );
10670 }
10671
10672 #[test]
10673 fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
10674 let (tool_name, _args) = normalized_tool_call_for_execution(
10675 "shell",
10676 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10677 false,
10678 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."),
10679 );
10680
10681 assert_eq!(tool_name, "shell");
10682 }
10683
10684 #[test]
10685 fn failing_path_parser_extracts_cargo_error_locations() {
10686 let output = r#"
10687BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
10688
10689error[E0412]: cannot find type `Foo` in this scope
10690 --> src/agent/conversation.rs:42:12
10691 |
1069242 | field: Foo,
10693 | ^^^ not found
10694
10695error[E0308]: mismatched types
10696 --> src/tools/file_ops.rs:100:5
10697 |
10698 = note: expected `String`, found `&str`
10699"#;
10700 let paths = parse_failing_paths_from_build_output(output);
10701 assert!(
10702 paths.iter().any(|p| p.contains("conversation.rs")),
10703 "should capture conversation.rs"
10704 );
10705 assert!(
10706 paths.iter().any(|p| p.contains("file_ops.rs")),
10707 "should capture file_ops.rs"
10708 );
10709 assert_eq!(paths.len(), 2, "no duplicates");
10710 }
10711
10712 #[test]
10713 fn failing_path_parser_ignores_macro_expansions() {
10714 let output = r#"
10715 --> <macro-expansion>:1:2
10716 --> src/real/file.rs:10:5
10717"#;
10718 let paths = parse_failing_paths_from_build_output(output);
10719 assert_eq!(paths.len(), 1);
10720 assert!(paths[0].contains("file.rs"));
10721 }
10722
10723 #[test]
10724 fn intent_router_picks_updates_for_update_questions() {
10725 assert_eq!(
10726 preferred_host_inspection_topic("is my PC up to date?"),
10727 Some("updates")
10728 );
10729 assert_eq!(
10730 preferred_host_inspection_topic("are there any pending Windows updates?"),
10731 Some("updates")
10732 );
10733 assert_eq!(
10734 preferred_host_inspection_topic("check for updates on my computer"),
10735 Some("updates")
10736 );
10737 }
10738
10739 #[test]
10740 fn intent_router_picks_security_for_antivirus_questions() {
10741 assert_eq!(
10742 preferred_host_inspection_topic("is my antivirus on?"),
10743 Some("security")
10744 );
10745 assert_eq!(
10746 preferred_host_inspection_topic("is Windows Defender running?"),
10747 Some("security")
10748 );
10749 assert_eq!(
10750 preferred_host_inspection_topic("is my PC protected?"),
10751 Some("security")
10752 );
10753 }
10754
10755 #[test]
10756 fn intent_router_picks_pending_reboot_for_restart_questions() {
10757 assert_eq!(
10758 preferred_host_inspection_topic("do I need to restart my PC?"),
10759 Some("pending_reboot")
10760 );
10761 assert_eq!(
10762 preferred_host_inspection_topic("is a reboot required?"),
10763 Some("pending_reboot")
10764 );
10765 assert_eq!(
10766 preferred_host_inspection_topic("is there a pending restart waiting?"),
10767 Some("pending_reboot")
10768 );
10769 }
10770
10771 #[test]
10772 fn intent_router_picks_disk_health_for_drive_health_questions() {
10773 assert_eq!(
10774 preferred_host_inspection_topic("is my hard drive dying?"),
10775 Some("disk_health")
10776 );
10777 assert_eq!(
10778 preferred_host_inspection_topic("check the disk health and SMART status"),
10779 Some("disk_health")
10780 );
10781 assert_eq!(
10782 preferred_host_inspection_topic("is my SSD healthy?"),
10783 Some("disk_health")
10784 );
10785 }
10786
10787 #[test]
10788 fn intent_router_picks_battery_for_battery_questions() {
10789 assert_eq!(
10790 preferred_host_inspection_topic("check my battery"),
10791 Some("battery")
10792 );
10793 assert_eq!(
10794 preferred_host_inspection_topic("how is my battery life?"),
10795 Some("battery")
10796 );
10797 assert_eq!(
10798 preferred_host_inspection_topic("what is my battery wear level?"),
10799 Some("battery")
10800 );
10801 }
10802
10803 #[test]
10804 fn intent_router_picks_recent_crashes_for_bsod_questions() {
10805 assert_eq!(
10806 preferred_host_inspection_topic("why did my PC restart by itself?"),
10807 Some("recent_crashes")
10808 );
10809 assert_eq!(
10810 preferred_host_inspection_topic("did my computer BSOD recently?"),
10811 Some("recent_crashes")
10812 );
10813 assert_eq!(
10814 preferred_host_inspection_topic("show me any recent app crashes"),
10815 Some("recent_crashes")
10816 );
10817 }
10818
10819 #[test]
10820 fn intent_router_picks_scheduled_tasks_for_task_questions() {
10821 assert_eq!(
10822 preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
10823 Some("scheduled_tasks")
10824 );
10825 assert_eq!(
10826 preferred_host_inspection_topic("show me the task scheduler"),
10827 Some("scheduled_tasks")
10828 );
10829 }
10830
10831 #[test]
10832 fn intent_router_picks_dev_conflicts_for_conflict_questions() {
10833 assert_eq!(
10834 preferred_host_inspection_topic("are there any dev environment conflicts?"),
10835 Some("dev_conflicts")
10836 );
10837 assert_eq!(
10838 preferred_host_inspection_topic("why is python pointing to the wrong version?"),
10839 Some("dev_conflicts")
10840 );
10841 }
10842
10843 #[test]
10844 fn shell_guard_catches_windows_update_commands() {
10845 assert!(shell_looks_like_structured_host_inspection(
10846 "Get-WindowsUpdateLog | Select-Object -Last 50"
10847 ));
10848 assert!(shell_looks_like_structured_host_inspection(
10849 "$sess = New-Object -ComObject Microsoft.Update.Session"
10850 ));
10851 assert!(shell_looks_like_structured_host_inspection(
10852 "Get-Service wuauserv"
10853 ));
10854 assert!(shell_looks_like_structured_host_inspection(
10855 "Get-MpComputerStatus"
10856 ));
10857 assert!(shell_looks_like_structured_host_inspection(
10858 "Get-PhysicalDisk"
10859 ));
10860 assert!(shell_looks_like_structured_host_inspection(
10861 "Get-CimInstance Win32_Battery"
10862 ));
10863 assert!(shell_looks_like_structured_host_inspection(
10864 "Get-WinEvent -FilterHashtable @{Id=41}"
10865 ));
10866 assert!(shell_looks_like_structured_host_inspection(
10867 "Get-ScheduledTask | Where-Object State -ne Disabled"
10868 ));
10869 }
10870
10871 #[test]
10872 fn intent_router_picks_permissions_for_acl_questions() {
10873 assert_eq!(
10874 preferred_host_inspection_topic("who has permission to access the downloads folder?"),
10875 Some("permissions")
10876 );
10877 assert_eq!(
10878 preferred_host_inspection_topic("audit the ntfs permissions for this path"),
10879 Some("permissions")
10880 );
10881 }
10882
10883 #[test]
10884 fn intent_router_picks_login_history_for_logon_questions() {
10885 assert_eq!(
10886 preferred_host_inspection_topic("who logged in recently on this machine?"),
10887 Some("login_history")
10888 );
10889 assert_eq!(
10890 preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
10891 Some("login_history")
10892 );
10893 }
10894
10895 #[test]
10896 fn intent_router_picks_share_access_for_unc_questions() {
10897 assert_eq!(
10898 preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
10899 Some("share_access")
10900 );
10901 assert_eq!(
10902 preferred_host_inspection_topic("test accessibility of a network share"),
10903 Some("share_access")
10904 );
10905 }
10906
10907 #[test]
10908 fn intent_router_picks_registry_audit_for_persistence_questions() {
10909 assert_eq!(
10910 preferred_host_inspection_topic(
10911 "audit my registry for persistence hacks or debugger hijacking"
10912 ),
10913 Some("registry_audit")
10914 );
10915 assert_eq!(
10916 preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
10917 Some("registry_audit")
10918 );
10919 }
10920
10921 #[test]
10922 fn intent_router_picks_network_stats_for_mbps_questions() {
10923 assert_eq!(
10924 preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
10925 Some("network_stats")
10926 );
10927 }
10928
10929 #[test]
10930 fn intent_router_picks_processes_for_cpu_percentage_questions() {
10931 assert_eq!(
10932 preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
10933 Some("processes")
10934 );
10935 }
10936
10937 #[test]
10938 fn intent_router_picks_log_check_for_recent_window_questions() {
10939 assert_eq!(
10940 preferred_host_inspection_topic("show me system errors from the last 2 hours"),
10941 Some("log_check")
10942 );
10943 }
10944
10945 #[test]
10946 fn intent_router_picks_battery_for_health_and_cycles() {
10947 assert_eq!(
10948 preferred_host_inspection_topic("check my battery health and cycle count"),
10949 Some("battery")
10950 );
10951 }
10952
10953 #[test]
10954 fn intent_router_picks_thermal_for_throttling_questions() {
10955 assert_eq!(
10956 preferred_host_inspection_topic(
10957 "why is my laptop slow? check for overheating or throttling"
10958 ),
10959 Some("thermal")
10960 );
10961 assert_eq!(
10962 preferred_host_inspection_topic("show me the current cpu temp"),
10963 Some("thermal")
10964 );
10965 }
10966
10967 #[test]
10968 fn intent_router_picks_activation_for_genuine_questions() {
10969 assert_eq!(
10970 preferred_host_inspection_topic("is my windows genuine? check activation status"),
10971 Some("activation")
10972 );
10973 assert_eq!(
10974 preferred_host_inspection_topic("run slmgr to check my license state"),
10975 Some("activation")
10976 );
10977 }
10978
10979 #[test]
10980 fn intent_router_picks_patch_history_for_hotfix_questions() {
10981 assert_eq!(
10982 preferred_host_inspection_topic("show me the recently installed hotfixes"),
10983 Some("patch_history")
10984 );
10985 assert_eq!(
10986 preferred_host_inspection_topic(
10987 "list the windows update patch history for the last 48 hours"
10988 ),
10989 Some("patch_history")
10990 );
10991 }
10992
10993 #[test]
10994 fn intent_router_detects_multiple_symptoms_for_prerun() {
10995 let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
10996 assert!(topics.contains(&"thermal"));
10997 assert!(topics.contains(&"resource_load"));
10998 assert!(topics.contains(&"storage"));
10999 assert!(topics.len() >= 3);
11000 }
11001
11002 #[test]
11003 fn parse_unload_target_supports_current_and_all() {
11004 assert_eq!(
11005 ConversationManager::parse_unload_target("current").unwrap(),
11006 (None, false)
11007 );
11008 assert_eq!(
11009 ConversationManager::parse_unload_target("all").unwrap(),
11010 (None, true)
11011 );
11012 assert_eq!(
11013 ConversationManager::parse_unload_target("qwen/qwen3.5-9b").unwrap(),
11014 (Some("qwen/qwen3.5-9b".to_string()), false)
11015 );
11016 }
11017
11018 #[test]
11019 fn provider_model_controls_summary_mentions_ollama_limits() {
11020 let ollama = ConversationManager::provider_model_controls_summary("Ollama");
11021 assert!(ollama.contains("Ollama supports coding and embed model load/list/unload"));
11022 let lms = ConversationManager::provider_model_controls_summary("LM Studio");
11023 assert!(lms.contains("LM Studio supports coding and embed model load/unload"));
11024 }
11025}