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