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 use std::sync::OnceLock;
7594 static RE_VERSION: OnceLock<regex::Regex> = OnceLock::new();
7595 static RE_BUMP: OnceLock<regex::Regex> = OnceLock::new();
7596 let re = match flag {
7597 "-Version" => RE_VERSION.get_or_init(|| {
7598 regex::Regex::new(r#"(?i)-Version\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7599 }),
7600 "-Bump" => RE_BUMP.get_or_init(|| {
7601 regex::Regex::new(r#"(?i)-Bump\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7602 }),
7603 other => {
7604 let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(other));
7605 return regex::Regex::new(&pattern).ok().and_then(|re| {
7606 re.captures(command)
7607 .and_then(|c| c.get(1))
7608 .map(|m| m.as_str().to_string())
7609 });
7610 }
7611 };
7612 re.captures(command)?.get(1).map(|m| m.as_str().to_string())
7613}
7614
7615fn clean_shell_dns_token(token: &str) -> String {
7616 token
7617 .trim_matches(|c: char| {
7618 c.is_whitespace()
7619 || matches!(
7620 c,
7621 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
7622 )
7623 })
7624 .trim_end_matches(|c: char| matches!(c, ':' | '.'))
7625 .to_string()
7626}
7627
7628fn looks_like_dns_target(token: &str) -> bool {
7629 let cleaned = clean_shell_dns_token(token);
7630 if cleaned.is_empty() {
7631 return false;
7632 }
7633
7634 let lower = cleaned.to_ascii_lowercase();
7635 if matches!(
7636 lower.as_str(),
7637 "a" | "aaaa"
7638 | "mx"
7639 | "srv"
7640 | "txt"
7641 | "cname"
7642 | "ptr"
7643 | "soa"
7644 | "any"
7645 | "resolve-dnsname"
7646 | "nslookup"
7647 | "host"
7648 | "dig"
7649 | "powershell"
7650 | "-command"
7651 | "foreach-object"
7652 | "select-object"
7653 | "address"
7654 | "ipaddress"
7655 | "name"
7656 | "type"
7657 ) {
7658 return false;
7659 }
7660
7661 if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
7662 return true;
7663 }
7664
7665 cleaned.contains('.')
7666 && cleaned
7667 .chars()
7668 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
7669}
7670
7671fn dns_quoted_re() -> &'static regex::Regex {
7672 use std::sync::OnceLock;
7673 static RE: OnceLock<regex::Regex> = OnceLock::new();
7674 RE.get_or_init(|| regex::Regex::new(r#"['"]([^'"]+)['"]"#).expect("valid"))
7675}
7676
7677fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
7678 use std::sync::OnceLock;
7679 static RE1: OnceLock<regex::Regex> = OnceLock::new();
7680 static RE2: OnceLock<regex::Regex> = OnceLock::new();
7681 static RE3: OnceLock<regex::Regex> = OnceLock::new();
7682 let re1 = RE1.get_or_init(|| {
7683 regex::Regex::new(r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#).expect("valid")
7684 });
7685 let re2 = RE2.get_or_init(|| {
7686 regex::Regex::new(r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#)
7687 .expect("valid")
7688 });
7689 let re3 = RE3.get_or_init(|| {
7690 regex::Regex::new(
7691 r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
7692 )
7693 .expect("valid")
7694 });
7695 for re in [re1, re2, re3] {
7696 if let Some(value) = re
7697 .captures(command)
7698 .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
7699 .filter(|value| looks_like_dns_target(value))
7700 {
7701 return Some(value);
7702 }
7703 }
7704
7705 let quoted = dns_quoted_re();
7706 for captures in quoted.captures_iter(command) {
7707 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7708 if looks_like_dns_target(&candidate) {
7709 return Some(candidate);
7710 }
7711 }
7712
7713 command
7714 .split_whitespace()
7715 .map(clean_shell_dns_token)
7716 .find(|token| looks_like_dns_target(token))
7717}
7718
7719fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
7720 let quoted = dns_quoted_re();
7721 for captures in quoted.captures_iter(text) {
7722 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7723 if looks_like_dns_target(&candidate) {
7724 return Some(candidate);
7725 }
7726 }
7727
7728 text.split_whitespace()
7729 .map(clean_shell_dns_token)
7730 .find(|token| looks_like_dns_target(token))
7731}
7732
7733fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
7734 let lower = text.to_ascii_lowercase();
7735 if lower.contains("aaaa record") || lower.contains("ipv6 address") {
7736 Some("AAAA")
7737 } else if lower.contains("mx record") {
7738 Some("MX")
7739 } else if lower.contains("srv record") {
7740 Some("SRV")
7741 } else if lower.contains("txt record") {
7742 Some("TXT")
7743 } else if lower.contains("cname record") {
7744 Some("CNAME")
7745 } else if lower.contains("soa record") {
7746 Some("SOA")
7747 } else if lower.contains("ptr record") {
7748 Some("PTR")
7749 } else if lower.contains("a record")
7750 || (lower.contains("ip address") && lower.contains(" of "))
7751 || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
7752 {
7753 Some("A")
7754 } else {
7755 None
7756 }
7757}
7758
7759fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
7760 use std::sync::OnceLock;
7761 static RE: OnceLock<regex::Regex> = OnceLock::new();
7762 let re = RE.get_or_init(|| {
7763 regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").expect("valid")
7764 });
7765 re.captures(text)
7766 .and_then(|captures| captures.get(1))
7767 .and_then(|m| m.as_str().parse::<u32>().ok())
7768}
7769
7770fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
7771 let lower = text.to_ascii_lowercase();
7772 if lower.contains("security log") {
7773 Some("Security")
7774 } else if lower.contains("application log") {
7775 Some("Application")
7776 } else if lower.contains("system log") || lower.contains("system errors") {
7777 Some("System")
7778 } else if lower.contains("setup log") {
7779 Some("Setup")
7780 } else {
7781 None
7782 }
7783}
7784
7785fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
7786 let lower = text.to_ascii_lowercase();
7787 if lower.contains("critical") {
7788 Some("Critical")
7789 } else if lower.contains("error") || lower.contains("errors") {
7790 Some("Error")
7791 } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
7792 Some("Warning")
7793 } else if lower.contains("information")
7794 || lower.contains("informational")
7795 || lower.contains("info")
7796 {
7797 Some("Information")
7798 } else {
7799 None
7800 }
7801}
7802
7803fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
7804 use std::sync::OnceLock;
7805 static RE: OnceLock<regex::Regex> = OnceLock::new();
7806 let lower = text.to_ascii_lowercase();
7807 let re = RE.get_or_init(|| {
7808 regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b")
7809 .expect("valid")
7810 });
7811 if let Some(hours) = re
7812 .captures(&lower)
7813 .and_then(|captures| captures.get(1))
7814 .and_then(|m| m.as_str().parse::<u32>().ok())
7815 {
7816 return Some(hours);
7817 }
7818 if lower.contains("last hour") || lower.contains("past hour") {
7819 Some(1)
7820 } else if lower.contains("today") {
7821 Some(24)
7822 } else {
7823 None
7824 }
7825}
7826
7827fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
7828 let lower = command.to_ascii_lowercase();
7829 if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
7830 Some("AAAA")
7831 } else if lower.contains("-type mx") || lower.contains("-type=mx") {
7832 Some("MX")
7833 } else if lower.contains("-type srv") || lower.contains("-type=srv") {
7834 Some("SRV")
7835 } else if lower.contains("-type txt") || lower.contains("-type=txt") {
7836 Some("TXT")
7837 } else if lower.contains("-type cname") || lower.contains("-type=cname") {
7838 Some("CNAME")
7839 } else if lower.contains("-type soa") || lower.contains("-type=soa") {
7840 Some("SOA")
7841 } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
7842 Some("PTR")
7843 } else if lower.contains("-type a") || lower.contains("-type=a") {
7844 Some("A")
7845 } else {
7846 extract_dns_record_type_from_text(command)
7847 }
7848}
7849
7850fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
7851 let mut args = serde_json::json!({ "topic": topic });
7852 if topic == "dns_lookup" {
7853 if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
7854 args.as_object_mut()
7855 .unwrap()
7856 .insert("name".to_string(), Value::String(name));
7857 }
7858 let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
7859 args.as_object_mut()
7860 .unwrap()
7861 .insert("type".to_string(), Value::String(record_type.to_string()));
7862 } else if topic == "event_query" {
7863 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7864 args.as_object_mut().unwrap().insert(
7865 "event_id".to_string(),
7866 Value::Number(serde_json::Number::from(event_id)),
7867 );
7868 }
7869 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7870 args.as_object_mut()
7871 .unwrap()
7872 .insert("log".to_string(), Value::String(log_name.to_string()));
7873 }
7874 if let Some(level) = extract_event_query_level_from_text(prompt) {
7875 args.as_object_mut()
7876 .unwrap()
7877 .insert("level".to_string(), Value::String(level.to_string()));
7878 }
7879 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7880 args.as_object_mut().unwrap().insert(
7881 "hours".to_string(),
7882 Value::Number(serde_json::Number::from(hours)),
7883 );
7884 }
7885 }
7886 args
7887}
7888
7889fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7890 let workflow = preferred_maintainer_workflow(prompt)?;
7891 let lower = prompt.to_ascii_lowercase();
7892 match workflow {
7893 "clean" => Some(serde_json::json!({
7894 "workflow": "clean",
7895 "deep": lower.contains("deep clean")
7896 || lower.contains("deep cleanup")
7897 || lower.contains("deep"),
7898 "reset": lower.contains("reset"),
7899 "prune_dist": lower.contains("prune dist")
7900 || lower.contains("prune old dist")
7901 || lower.contains("prune old artifacts")
7902 || lower.contains("old dist artifacts")
7903 || lower.contains("old artifacts"),
7904 })),
7905 "package_windows" => Some(serde_json::json!({
7906 "workflow": "package_windows",
7907 "installer": lower.contains("installer") || lower.contains("setup.exe"),
7908 "add_to_path": lower.contains("addtopath")
7909 || lower.contains("add to path")
7910 || lower.contains("update path")
7911 || lower.contains("refresh path"),
7912 })),
7913 "release" => {
7914 use std::sync::OnceLock;
7915 static SEMVER_RE: OnceLock<regex::Regex> = OnceLock::new();
7916 let version = SEMVER_RE
7917 .get_or_init(|| regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#).expect("valid"))
7918 .captures(prompt)
7919 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
7920 let bump = if lower.contains("patch") {
7921 Some("patch")
7922 } else if lower.contains("minor") {
7923 Some("minor")
7924 } else if lower.contains("major") {
7925 Some("major")
7926 } else {
7927 None
7928 };
7929 let mut args = serde_json::json!({
7930 "workflow": "release",
7931 "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
7932 "add_to_path": lower.contains("addtopath")
7933 || lower.contains("add to path")
7934 || lower.contains("update path"),
7935 "skip_installer": lower.contains("skip installer"),
7936 "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
7937 "publish_voice_crate": lower.contains("publish voice crate")
7938 || lower.contains("publish hematite-kokoros"),
7939 });
7940 if let Some(version) = version {
7941 args["version"] = Value::String(version);
7942 }
7943 if let Some(bump) = bump {
7944 args["bump"] = Value::String(bump.to_string());
7945 }
7946 Some(args)
7947 }
7948 _ => None,
7949 }
7950}
7951
7952fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7953 if is_scaffold_request(prompt) {
7954 return None;
7955 }
7956 let workflow = preferred_workspace_workflow(prompt)?;
7957 let lower = prompt.to_ascii_lowercase();
7958 let trimmed = prompt.trim();
7959
7960 if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
7961 return Some(serde_json::json!({
7962 "workflow": "command",
7963 "command": command,
7964 }));
7965 }
7966
7967 if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
7968 return Some(serde_json::json!({
7969 "workflow": "script_path",
7970 "path": path,
7971 }));
7972 }
7973
7974 match workflow {
7975 "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
7976 "workflow": workflow,
7977 })),
7978 "script" => {
7979 let package_script = if lower.contains("npm run ") {
7980 extract_word_after(&lower, "npm run ")
7981 } else if lower.contains("pnpm run ") {
7982 extract_word_after(&lower, "pnpm run ")
7983 } else if lower.contains("bun run ") {
7984 extract_word_after(&lower, "bun run ")
7985 } else if lower.contains("yarn ") {
7986 extract_word_after(&lower, "yarn ")
7987 } else {
7988 None
7989 };
7990
7991 if let Some(name) = package_script {
7992 return Some(serde_json::json!({
7993 "workflow": "package_script",
7994 "name": name,
7995 }));
7996 }
7997
7998 if let Some(name) = extract_word_after(&lower, "just ") {
7999 return Some(serde_json::json!({
8000 "workflow": "just",
8001 "name": name,
8002 }));
8003 }
8004 if let Some(name) = extract_word_after(&lower, "make ") {
8005 return Some(serde_json::json!({
8006 "workflow": "make",
8007 "name": name,
8008 }));
8009 }
8010 if let Some(name) = extract_word_after(&lower, "task ") {
8011 return Some(serde_json::json!({
8012 "workflow": "task",
8013 "name": name,
8014 }));
8015 }
8016
8017 None
8018 }
8019 _ => None,
8020 }
8021}
8022
8023fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
8024 let lower = prompt.to_ascii_lowercase();
8025 for prefix in [
8026 "cargo ",
8027 "npm ",
8028 "pnpm ",
8029 "yarn ",
8030 "bun ",
8031 "pytest",
8032 "go build",
8033 "go test",
8034 "make ",
8035 "just ",
8036 "task ",
8037 "./gradlew",
8038 ".\\gradlew",
8039 ] {
8040 if let Some(index) = lower.find(prefix) {
8041 return Some(prompt[index..].trim().trim_matches('`').to_string());
8042 }
8043 }
8044 None
8045}
8046
8047fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
8048 let normalized = prompt.replace('\\', "/");
8049 for token in normalized.split_whitespace() {
8050 let candidate = token
8051 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8052 .trim_start_matches("./");
8053 if candidate.starts_with("scripts/")
8054 && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
8055 .iter()
8056 .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
8057 {
8058 return Some(candidate.to_string());
8059 }
8060 }
8061 None
8062}
8063
8064fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
8065 let start = haystack.find(prefix)? + prefix.len();
8066 let tail = &haystack[start..];
8067 let word = tail
8068 .split_whitespace()
8069 .next()
8070 .map(str::trim)
8071 .filter(|value| !value.is_empty())?;
8072 Some(
8073 word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8074 .to_string(),
8075 )
8076}
8077
8078fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
8079 let lower = command.to_ascii_lowercase();
8080 if lower.contains("clean.ps1") {
8081 return Some(serde_json::json!({
8082 "workflow": "clean",
8083 "deep": lower.contains("-deep"),
8084 "reset": lower.contains("-reset"),
8085 "prune_dist": lower.contains("-prunedist"),
8086 }));
8087 }
8088 if lower.contains("package-windows.ps1") {
8089 return Some(serde_json::json!({
8090 "workflow": "package_windows",
8091 "installer": lower.contains("-installer"),
8092 "add_to_path": lower.contains("-addtopath"),
8093 }));
8094 }
8095 if lower.contains("release.ps1") {
8096 let version = extract_release_arg(command, "-Version");
8097 let bump = extract_release_arg(command, "-Bump");
8098 if version.is_none() && bump.is_none() {
8099 return Some(serde_json::json!({
8100 "workflow": "release"
8101 }));
8102 }
8103 let mut args = serde_json::json!({
8104 "workflow": "release",
8105 "push": lower.contains("-push"),
8106 "add_to_path": lower.contains("-addtopath"),
8107 "skip_installer": lower.contains("-skipinstaller"),
8108 "publish_crates": lower.contains("-publishcrates"),
8109 "publish_voice_crate": lower.contains("-publishvoicecrate"),
8110 });
8111 if let Some(version) = version {
8112 args["version"] = Value::String(version);
8113 }
8114 if let Some(bump) = bump {
8115 args["bump"] = Value::String(bump);
8116 }
8117 return Some(args);
8118 }
8119 None
8120}
8121
8122fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
8123 let lower = command.to_ascii_lowercase();
8124 if lower.contains("clean.ps1")
8125 || lower.contains("package-windows.ps1")
8126 || lower.contains("release.ps1")
8127 {
8128 return None;
8129 }
8130
8131 if let Some(path) = extract_workspace_script_path_from_prompt(command) {
8132 return Some(serde_json::json!({
8133 "workflow": "script_path",
8134 "path": path,
8135 }));
8136 }
8137
8138 let looks_like_workspace_command = [
8139 "cargo ",
8140 "npm ",
8141 "pnpm ",
8142 "yarn ",
8143 "bun ",
8144 "pytest",
8145 "go build",
8146 "go test",
8147 "make ",
8148 "just ",
8149 "task ",
8150 "./gradlew",
8151 ".\\gradlew",
8152 ]
8153 .iter()
8154 .any(|needle| lower.contains(needle));
8155
8156 if looks_like_workspace_command {
8157 Some(serde_json::json!({
8158 "workflow": "command",
8159 "command": command.trim(),
8160 }))
8161 } else {
8162 None
8163 }
8164}
8165
8166fn rewrite_host_tool_call(
8167 tool_name: &mut String,
8168 args: &mut Value,
8169 latest_user_prompt: Option<&str>,
8170) {
8171 if *tool_name == "shell" {
8172 let command = args
8173 .get("command")
8174 .and_then(|value| value.as_str())
8175 .unwrap_or("");
8176 if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
8177 *tool_name = "run_hematite_maintainer_workflow".to_string();
8178 *args = maintainer_workflow_args;
8179 return;
8180 }
8181 if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
8182 *tool_name = "run_workspace_workflow".to_string();
8183 *args = workspace_workflow_args;
8184 return;
8185 }
8186 }
8187 let is_surgical_tool = matches!(
8188 tool_name.as_str(),
8189 "create_directory"
8190 | "write_file"
8191 | "edit_file"
8192 | "patch_hunk"
8193 | "multi_replace_file_content"
8194 | "replace_file_content"
8195 | "move_file"
8196 | "delete_file"
8197 );
8198
8199 if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
8200 if let Some(prompt_args) =
8201 latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
8202 {
8203 *tool_name = "run_hematite_maintainer_workflow".to_string();
8204 *args = prompt_args;
8205 return;
8206 }
8207 }
8208 let is_generic_command_trigger = matches!(
8212 tool_name.as_str(),
8213 "shell" | "run_command" | "workflow" | "run"
8214 );
8215 if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
8216 if let Some(prompt_args) =
8217 latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
8218 {
8219 *tool_name = "run_workspace_workflow".to_string();
8220 *args = prompt_args;
8221 return;
8222 }
8223 }
8224 if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
8225 *tool_name = "inspect_host".to_string();
8226 *args = serde_json::json!({
8227 "topic": "fix_plan"
8228 });
8229 }
8230 fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
8231 fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
8232 fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
8233 fill_missing_event_query_args(tool_name, args, latest_user_prompt);
8234}
8235
8236fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
8237 format!(
8238 "{}:{}",
8239 tool_name,
8240 serde_json::to_string(args).unwrap_or_default()
8241 )
8242}
8243
8244fn normalized_tool_call_for_execution(
8245 tool_name: &str,
8246 raw_arguments: &Value,
8247 gemma4_model: bool,
8248 latest_user_prompt: Option<&str>,
8249) -> (String, Value) {
8250 let mut normalized_name = tool_name.to_string();
8251 let mut args = if gemma4_model {
8252 let raw_str = raw_arguments.to_string();
8253 let normalized_str =
8254 crate::agent::inference::normalize_tool_argument_string(tool_name, &raw_str);
8255 serde_json::from_str::<Value>(&normalized_str).unwrap_or_else(|_| raw_arguments.clone())
8256 } else {
8257 raw_arguments.clone()
8258 };
8259 rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
8260 (normalized_name, args)
8261}
8262
8263#[cfg(test)]
8264fn normalized_tool_call_key_for_dedupe(
8265 tool_name: &str,
8266 raw_arguments: &str,
8267 gemma4_model: bool,
8268 latest_user_prompt: Option<&str>,
8269) -> String {
8270 let val = serde_json::from_str(raw_arguments).unwrap_or(Value::Null);
8271 let (normalized_name, args) =
8272 normalized_tool_call_for_execution(tool_name, &val, gemma4_model, latest_user_prompt);
8273 canonical_tool_call_key(&normalized_name, &args)
8274}
8275
8276impl ConversationManager {
8277 fn check_authorization(
8279 &self,
8280 name: &str,
8281 args: &serde_json::Value,
8282 config: &crate::agent::config::HematiteConfig,
8283 yolo_flag: bool,
8284 ) -> crate::agent::permission_enforcer::AuthorizationDecision {
8285 crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
8286 }
8287
8288 async fn process_tool_call(
8290 &self,
8291 mut call: ToolCallFn,
8292 config: crate::agent::config::HematiteConfig,
8293 yolo: bool,
8294 tx: mpsc::Sender<InferenceEvent>,
8295 real_id: String,
8296 budget_tokens: usize,
8297 ) -> ToolExecutionOutcome {
8298 let mut msg_results = Vec::new();
8299 let mut latest_target_dir = None;
8300 let mut plan_drafted_this_turn = false;
8301 let mut parsed_plan_handoff = None;
8302 let gemma4_model =
8303 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
8304 let (normalized_name, mut args) = normalized_tool_call_for_execution(
8305 &call.name,
8306 &call.arguments,
8307 gemma4_model,
8308 self.history
8309 .last()
8310 .and_then(|m| m.content.as_str().split('\n').last()),
8311 );
8312 call.name = normalized_name;
8313 let last_user_prompt = self
8314 .history
8315 .iter()
8316 .rev()
8317 .find(|message| message.role == "user")
8318 .map(|message| message.content.as_str());
8319 rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
8320 if self
8321 .plan_execution_active
8322 .load(std::sync::atomic::Ordering::SeqCst)
8323 {
8324 let fallback_target = self
8325 .session_memory
8326 .current_plan
8327 .as_ref()
8328 .and_then(|plan| plan.target_files.first().map(String::as_str));
8329 let explicit_query = last_user_prompt.and_then(extract_explicit_web_search_query);
8330 if let Some((repaired_args, note)) = repaired_plan_tool_args(
8331 &call.name,
8332 &args,
8333 std::path::Path::new(".hematite/TASK.md").exists(),
8334 fallback_target,
8335 explicit_query.as_deref(),
8336 ) {
8337 args = repaired_args;
8338 let _ = tx.send(InferenceEvent::Thought(note)).await;
8339 }
8340 }
8341
8342 let display = format_tool_display(&call.name, &args);
8343 let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
8344 let auth = self.check_authorization(&call.name, &args, &config, yolo);
8345
8346 let decision_result = match precondition_result {
8348 Err(e) => Err(e),
8349 Ok(_) => match auth {
8350 crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
8351 crate::agent::permission_enforcer::AuthorizationDecision::Ask {
8352 reason,
8353 source: _,
8354 } => {
8355 let mutation_label =
8356 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8357 let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
8358 let _ = tx
8359 .send(InferenceEvent::ApprovalRequired {
8360 id: real_id.clone(),
8361 name: call.name.clone(),
8362 display: format!("{}\nWhy: {}", display, reason),
8363 diff: None,
8364 mutation_label,
8365 responder: approve_tx,
8366 })
8367 .await;
8368
8369 match approve_rx.await {
8370 Ok(true) => Ok(()),
8371 _ => Err("Declined by user".into()),
8372 }
8373 }
8374 crate::agent::permission_enforcer::AuthorizationDecision::Deny {
8375 reason, ..
8376 } => Err(reason),
8377 },
8378 };
8379 let blocked_by_policy =
8380 matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
8381
8382 let (output, is_error) = match decision_result {
8384 Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
8385 Err(e) => (format!("Error: {}", e), true),
8386 Ok(_) => {
8387 let _ = tx
8388 .send(InferenceEvent::ToolCallStart {
8389 id: real_id.clone(),
8390 name: call.name.clone(),
8391 args: display.clone(),
8392 })
8393 .await;
8394
8395 let result = if call.name.starts_with("lsp_") {
8396 let lsp = self.lsp_manager.clone();
8397 let path = args
8398 .get("path")
8399 .and_then(|v| v.as_str())
8400 .unwrap_or("")
8401 .to_string();
8402 let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8403 let character =
8404 args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8405
8406 match call.name.as_str() {
8407 "lsp_definitions" => {
8408 crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
8409 .await
8410 }
8411 "lsp_references" => {
8412 crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
8413 .await
8414 }
8415 "lsp_hover" => {
8416 crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
8417 }
8418 "lsp_search_symbol" => {
8419 let query = args
8420 .get("query")
8421 .and_then(|v| v.as_str())
8422 .unwrap_or_default()
8423 .to_string();
8424 crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
8425 }
8426 "lsp_rename_symbol" => {
8427 let new_name = args
8428 .get("new_name")
8429 .and_then(|v| v.as_str())
8430 .unwrap_or_default()
8431 .to_string();
8432 crate::tools::lsp_tools::lsp_rename_symbol(
8433 lsp, path, line, character, new_name,
8434 )
8435 .await
8436 }
8437 "lsp_get_diagnostics" => {
8438 crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
8439 }
8440 _ => Err(format!("Unknown LSP tool: {}", call.name)),
8441 }
8442 } else if call.name == "auto_pin_context" {
8443 let pts = args.get("paths").and_then(|v| v.as_array());
8444 let reason = args
8445 .get("reason")
8446 .and_then(|v| v.as_str())
8447 .unwrap_or("uninformed scoping");
8448 if let Some(arr) = pts {
8449 let mut pinned = Vec::new();
8450 {
8451 let mut guard = self.pinned_files.lock().await;
8452 const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; for v in arr.iter().take(3) {
8455 if let Some(p) = v.as_str() {
8456 if let Ok(meta) = std::fs::metadata(p) {
8457 if meta.len() > MAX_PINNED_SIZE {
8458 let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
8459 continue;
8460 }
8461 if let Ok(content) = std::fs::read_to_string(p) {
8462 guard.insert(p.to_string(), content);
8463 pinned.push(p.to_string());
8464 }
8465 }
8466 }
8467 }
8468 }
8469 let msg = format!(
8470 "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
8471 pinned.join(", "),
8472 reason
8473 );
8474 let _ = tx
8475 .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
8476 .await;
8477 Ok(msg)
8478 } else {
8479 Err("Missing 'paths' array for auto_pin_context.".to_string())
8480 }
8481 } else if call.name == "list_pinned" {
8482 let paths_msg = {
8483 let pinned = self.pinned_files.lock().await;
8484 if pinned.is_empty() {
8485 "No files are currently pinned.".to_string()
8486 } else {
8487 let paths: Vec<_> = pinned.keys().cloned().collect();
8488 format!(
8489 "Currently pinned files in active memory:\n- {}",
8490 paths.join("\n- ")
8491 )
8492 }
8493 };
8494 Ok(paths_msg)
8495 } else if call.name.starts_with("mcp__") {
8496 let mut mcp = self.mcp_manager.lock().await;
8497 match mcp.call_tool(&call.name, &args).await {
8498 Ok(res) => Ok(res),
8499 Err(e) => Err(e.to_string()),
8500 }
8501 } else if call.name == "swarm" {
8502 let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
8504 let max_workers = args
8505 .get("max_workers")
8506 .and_then(|v| v.as_u64())
8507 .unwrap_or(3) as usize;
8508
8509 let mut task_objs = Vec::new();
8510 if let Value::Array(arr) = tasks_val {
8511 for v in arr {
8512 let id = v
8513 .get("id")
8514 .and_then(|x| x.as_str())
8515 .unwrap_or("?")
8516 .to_string();
8517 let target = v
8518 .get("target")
8519 .and_then(|x| x.as_str())
8520 .unwrap_or("?")
8521 .to_string();
8522 let instruction = v
8523 .get("instruction")
8524 .and_then(|x| x.as_str())
8525 .unwrap_or("?")
8526 .to_string();
8527 task_objs.push(crate::agent::parser::WorkerTask {
8528 id,
8529 target,
8530 instruction,
8531 });
8532 }
8533 }
8534
8535 if task_objs.is_empty() {
8536 Err("No tasks provided for swarm.".to_string())
8537 } else {
8538 let (swarm_tx_internal, mut swarm_rx_internal) =
8539 tokio::sync::mpsc::channel(32);
8540 let tx_forwarder = tx.clone();
8541
8542 tokio::spawn(async move {
8544 while let Some(msg) = swarm_rx_internal.recv().await {
8545 match msg {
8546 crate::agent::swarm::SwarmMessage::Progress(id, p) => {
8547 let _ = tx_forwarder
8548 .send(InferenceEvent::Thought(format!(
8549 "Swarm [{}]: {}% complete",
8550 id, p
8551 )))
8552 .await;
8553 }
8554 crate::agent::swarm::SwarmMessage::ReviewRequest {
8555 worker_id,
8556 file_path,
8557 before: _,
8558 after: _,
8559 tx,
8560 } => {
8561 let (approve_tx, approve_rx) =
8562 tokio::sync::oneshot::channel::<bool>();
8563 let display = format!(
8564 "Swarm worker [{}]: Integrated changes into {:?}",
8565 worker_id, file_path
8566 );
8567 let _ = tx_forwarder
8568 .send(InferenceEvent::ApprovalRequired {
8569 id: format!("swarm_{}", worker_id),
8570 name: "swarm_apply".to_string(),
8571 display,
8572 diff: None,
8573 mutation_label: Some(
8574 "Swarm Agentic Integration".to_string(),
8575 ),
8576 responder: approve_tx,
8577 })
8578 .await;
8579 if let Ok(approved) = approve_rx.await {
8580 let response = if approved {
8581 crate::agent::swarm::ReviewResponse::Accept
8582 } else {
8583 crate::agent::swarm::ReviewResponse::Reject
8584 };
8585 let _ = tx.send(response);
8586 }
8587 }
8588 crate::agent::swarm::SwarmMessage::Done => {}
8589 }
8590 }
8591 });
8592
8593 let coordinator = self.swarm_coordinator.clone();
8594 match coordinator
8595 .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
8596 .await
8597 {
8598 Ok(_) => Ok(
8599 "Swarm execution completed. Check files for integration results."
8600 .to_string(),
8601 ),
8602 Err(e) => Err(format!("Swarm failure: {}", e)),
8603 }
8604 }
8605 } else if call.name == "vision_analyze" {
8606 crate::tools::vision::vision_analyze(&self.engine, &args).await
8607 } else if matches!(
8608 call.name.as_str(),
8609 "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
8610 ) && !yolo
8611 {
8612 let diff_result = match call.name.as_str() {
8618 "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
8619 "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
8620 "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
8621 _ => crate::tools::file_ops::compute_msr_diff(&args),
8622 };
8623 match diff_result {
8624 Ok(diff_text) => {
8625 let path_label =
8626 args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
8627 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
8628 let mutation_label =
8629 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8630 let _ = tx
8631 .send(InferenceEvent::ApprovalRequired {
8632 id: real_id.clone(),
8633 name: call.name.clone(),
8634 display: format!("Edit preview: {}", path_label),
8635 diff: Some(diff_text),
8636 mutation_label,
8637 responder: appr_tx,
8638 })
8639 .await;
8640 match appr_rx.await {
8641 Ok(true) => {
8642 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8643 }
8644 _ => Err("Edit declined by user.".into()),
8645 }
8646 }
8647 Err(_) => dispatch_tool(&call.name, &args, &config, budget_tokens).await,
8650 }
8651 } else if call.name == "verify_build" {
8652 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
8655 } else if call.name == "shell" {
8656 crate::tools::shell::execute_streaming(&args, tx.clone(), budget_tokens).await
8659 } else {
8660 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8661 };
8662
8663 match result {
8664 Ok(o) => (o, false),
8665 Err(e) => (format!("Error: {}", e), true),
8666 }
8667 }
8668 };
8669
8670 {
8672 if let Ok(mut econ) = self.engine.economics.lock() {
8673 econ.record_tool(&call.name, !is_error);
8674 }
8675 }
8676
8677 if !is_error {
8678 if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
8679 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8680 if call.name == "inspect_lines" {
8681 self.record_line_inspection(path).await;
8682 } else {
8683 self.record_read_observation(path).await;
8684 }
8685 }
8686 }
8687
8688 if call.name == "verify_build" {
8689 let ok = output.contains("BUILD OK")
8690 || output.contains("BUILD SUCCESS")
8691 || output.contains("BUILD OKAY");
8692 self.record_verify_build_result(ok, &output).await;
8693 }
8694
8695 if matches!(
8696 call.name.as_str(),
8697 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
8698 ) || is_mcp_mutating_tool(&call.name)
8699 {
8700 if call.name == "write_file" {
8701 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8702 if path.ends_with("PLAN.md") {
8703 plan_drafted_this_turn = true;
8704 if !is_error {
8705 if let Some(content) = args.get("content").and_then(|v| v.as_str())
8706 {
8707 let resolved = crate::tools::file_ops::resolve_candidate(path);
8708 let _ = crate::tools::plan::sync_plan_blueprint_for_path(
8709 &resolved, content,
8710 );
8711 parsed_plan_handoff =
8712 crate::tools::plan::parse_plan_handoff(content);
8713 }
8714 }
8715 }
8716 }
8717 }
8718 self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
8719 .await;
8720 }
8721
8722 if call.name == "create_directory" {
8723 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8724 let resolved = crate::tools::file_ops::resolve_candidate(path);
8725 latest_target_dir = Some(resolved.to_string_lossy().to_string());
8726 }
8727 }
8728
8729 if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
8730 msg_results.push(receipt);
8731 }
8732 }
8733
8734 if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
8738 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
8739 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
8740 let ext = std::path::Path::new(path)
8741 .extension()
8742 .and_then(|e| e.to_str())
8743 .unwrap_or("");
8744 const SKIP_EXTS: &[&str] = &[
8745 "md",
8746 "toml",
8747 "json",
8748 "txt",
8749 "yml",
8750 "yaml",
8751 "cfg",
8752 "csv",
8753 "lock",
8754 "gitignore",
8755 ];
8756 let line_count = content.lines().count();
8757 const WEB_EXTS: &[&str] = &[
8760 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
8761 ];
8762 let is_web = WEB_EXTS.contains(&ext);
8763 let min_lines = if is_web { 5 } else { 50 };
8764 if !path.is_empty()
8765 && !content.is_empty()
8766 && !SKIP_EXTS.contains(&ext)
8767 && line_count >= min_lines
8768 {
8769 if let Some(critique) = self.run_critic_check(path, content, &tx).await {
8770 msg_results.push(ChatMessage::system(&format!(
8771 "[CRITIC AUTO-FIX REQUIRED — {}]\n\
8772 Fix ALL issues below before sending your final response. \
8773 Call the appropriate edit tools now.\n\n{}",
8774 path, critique
8775 )));
8776 }
8777 }
8778 }
8779
8780 ToolExecutionOutcome {
8781 call_id: real_id,
8782 tool_name: call.name,
8783 args,
8784 output,
8785 is_error,
8786 blocked_by_policy,
8787 msg_results,
8788 latest_target_dir,
8789 plan_drafted_this_turn,
8790 parsed_plan_handoff,
8791 }
8792 }
8793}
8794
8795struct ToolExecutionOutcome {
8798 call_id: String,
8799 tool_name: String,
8800 args: Value,
8801 output: String,
8802 is_error: bool,
8803 blocked_by_policy: bool,
8804 msg_results: Vec<ChatMessage>,
8805 latest_target_dir: Option<String>,
8806 plan_drafted_this_turn: bool,
8807 parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
8808}
8809
8810#[derive(Clone)]
8811struct CachedToolResult {
8812 tool_name: String,
8813}
8814
8815fn is_code_like_path(path: &str) -> bool {
8816 let ext = std::path::Path::new(path)
8817 .extension()
8818 .and_then(|e| e.to_str())
8819 .unwrap_or("")
8820 .to_ascii_lowercase();
8821 matches!(
8822 ext.as_str(),
8823 "rs" | "js"
8824 | "ts"
8825 | "tsx"
8826 | "jsx"
8827 | "py"
8828 | "go"
8829 | "java"
8830 | "c"
8831 | "cpp"
8832 | "cc"
8833 | "h"
8834 | "hpp"
8835 | "cs"
8836 | "swift"
8837 | "kt"
8838 | "kts"
8839 | "rb"
8840 | "php"
8841 )
8842}
8843
8844pub fn format_tool_display(name: &str, args: &Value) -> String {
8847 let get = |key: &str| {
8848 args.get(key)
8849 .and_then(|v| v.as_str())
8850 .unwrap_or("")
8851 .to_string()
8852 };
8853 match name {
8854 "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
8855 "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
8856 "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
8857 "describe_toolchain" => format!("describe toolchain {}", get("topic")),
8858 "inspect_host" => format!("inspect host {}", get("topic")),
8859 "write_file"
8860 | "read_file"
8861 | "edit_file"
8862 | "patch_hunk"
8863 | "inspect_lines"
8864 | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
8865 "grep_files" => format!(
8866 "grep_files pattern='{}' path='{}'",
8867 get("pattern"),
8868 get("path")
8869 ),
8870 "list_files" => format!("list_files `{}`", get("path")),
8871 "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
8872 _ => {
8873 let rep = format!("{} {:?}", name, args);
8875 if rep.len() > 100 {
8876 format!("{}... (truncated)", &rep[..100])
8877 } else {
8878 rep
8879 }
8880 }
8881 }
8882}
8883
8884pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
8887 let lower = command.to_ascii_lowercase();
8888 [
8889 "$env:path",
8890 "pathvariable",
8891 "pip --version",
8892 "pipx --version",
8893 "winget --version",
8894 "choco",
8895 "scoop",
8896 "get-childitem",
8897 "gci ",
8898 "where.exe",
8899 "where ",
8900 "cargo --version",
8901 "rustc --version",
8902 "git --version",
8903 "node --version",
8904 "npm --version",
8905 "pnpm --version",
8906 "python --version",
8907 "python3 --version",
8908 "deno --version",
8909 "go version",
8910 "dotnet --version",
8911 "uv --version",
8912 "netstat",
8913 "findstr",
8914 "get-nettcpconnection",
8915 "tcpconnection",
8916 "listening",
8917 "ss -",
8918 "ss ",
8919 "lsof",
8920 "tasklist",
8921 "ipconfig",
8922 "get-netipconfiguration",
8923 "get-netadapter",
8924 "route print",
8925 "ifconfig",
8926 "ip addr",
8927 "ip route",
8928 "resolv.conf",
8929 "get-service",
8930 "sc query",
8931 "systemctl",
8932 "service --status-all",
8933 "get-process",
8934 "working set",
8935 "ps -eo",
8936 "ps aux",
8937 "desktop",
8938 "downloads",
8939 "get-netfirewallprofile",
8940 "win32_powerplan",
8941 "win32_operatingsystem",
8942 "win32_processor",
8943 "wmic",
8944 "loadpercentage",
8945 "totalvisiblememory",
8946 "freephysicalmemory",
8947 "get-wmiobject",
8948 "get-ciminstance",
8949 "get-cpu",
8950 "processorname",
8951 "clockspeed",
8952 "top memory",
8953 "top cpu",
8954 "resource usage",
8955 "powercfg",
8956 "uptime",
8957 "lastbootuptime",
8958 "hklm:",
8960 "hkcu:",
8961 "hklm:\\",
8962 "hkcu:\\",
8963 "currentversion",
8964 "productname",
8965 "displayversion",
8966 "get-itemproperty",
8967 "get-itempropertyvalue",
8968 "get-windowsupdatelog",
8970 "windowsupdatelog",
8971 "microsoft.update.session",
8972 "createupdatesearcher",
8973 "wuauserv",
8974 "usoclient",
8975 "get-hotfix",
8976 "wu_",
8977 "get-mpcomputerstatus",
8979 "get-mppreference",
8980 "get-mpthreat",
8981 "start-mpscan",
8982 "win32_computersecurity",
8983 "softwarelicensingproduct",
8984 "enablelua",
8985 "get-netfirewallrule",
8986 "netfirewallprofile",
8987 "antivirus",
8988 "defenderstatus",
8989 "get-physicaldisk",
8991 "get-disk",
8992 "get-volume",
8993 "get-psdrive",
8994 "psdrive",
8995 "manage-bde",
8996 "bitlockervolume",
8997 "get-bitlockervolume",
8998 "get-smbencryptionstatus",
8999 "smbencryption",
9000 "get-netlanmanagerconnection",
9001 "lanmanager",
9002 "msstoragedriver_failurepredic",
9003 "win32_diskdrive",
9004 "smartstatus",
9005 "diskstatus",
9006 "get-counter",
9007 "intensity",
9008 "benchmark",
9009 "thrash",
9010 "get-item",
9011 "test-path",
9012 "gpresult",
9014 "applied gpo",
9015 "cert:\\",
9016 "cert:",
9017 "component based servicing",
9018 "componentstore",
9019 "get-computerinfo",
9020 "win32_computersystem",
9021 "win32_battery",
9023 "batterystaticdata",
9024 "batteryfullchargedcapacity",
9025 "batterystatus",
9026 "estimatedchargeremaining",
9027 "get-winevent",
9029 "eventid",
9030 "bugcheck",
9031 "kernelpower",
9032 "win32_ntlogevent",
9033 "filterhashtable",
9034 "get-scheduledtask",
9036 "get-scheduledtaskinfo",
9037 "schtasks",
9038 "taskscheduler",
9039 "get-acl",
9040 "icacls",
9041 "takeown",
9042 "event id 4624",
9043 "eventid 4624",
9044 "who logged in",
9045 "logon history",
9046 "login history",
9047 "get-smbshare",
9048 "net share",
9049 "mbps",
9050 "throughput",
9051 "whoami",
9052 "get-ciminstance win32",
9054 "get-wmiobject win32",
9055 "arp -",
9057 "arp -a",
9058 "tracert ",
9059 "traceroute ",
9060 "tracepath ",
9061 "get-dnsclientcache",
9062 "ipconfig /displaydns",
9063 "get-netroute",
9064 "get-netneighbor",
9065 "net view",
9066 "get-smbconnection",
9067 "get-smbmapping",
9068 "get-psdrive",
9069 "fdrespub",
9070 "fdphost",
9071 "ssdpsrv",
9072 "upnphost",
9073 "avahi-browse",
9074 "route print",
9075 "ip neigh",
9076 "get-pnpdevice -class audioendpoint",
9078 "get-pnpdevice -class media",
9079 "win32_sounddevice",
9080 "audiosrv",
9081 "audioendpointbuilder",
9082 "windows audio",
9083 "get-pnpdevice -class bluetooth",
9084 "bthserv",
9085 "bthavctpsvc",
9086 "btagservice",
9087 "bluetoothuserservice",
9088 "msiserver",
9089 "appxsvc",
9090 "clipsvc",
9091 "installservice",
9092 "desktopappinstaller",
9093 "microsoft.windowsstore",
9094 "get-appxpackage microsoft.desktopappinstaller",
9095 "get-appxpackage microsoft.windowsstore",
9096 "winget source",
9097 "winget --info",
9098 "onedrive",
9099 "onedrive.exe",
9100 "files on-demand",
9101 "known folder backup",
9102 "disablefilesyncngsc",
9103 "kfmsilentoptin",
9104 "kfmblockoptin",
9105 "get-process chrome",
9106 "get-process msedge",
9107 "get-process firefox",
9108 "get-process msedgewebview2",
9109 "google chrome",
9110 "microsoft edge",
9111 "mozilla firefox",
9112 "webview2",
9113 "msedgewebview2",
9114 "startmenuinternet",
9115 "urlassociations\\http\\userchoice",
9116 "urlassociations\\https\\userchoice",
9117 "software\\policies\\microsoft\\edge",
9118 "software\\policies\\google\\chrome",
9119 "get-winevent",
9120 "event id",
9121 "eventlog",
9122 "event viewer",
9123 "wevtutil",
9124 "cmdkey",
9125 "credential manager",
9126 "get-tpm",
9127 "confirm-securebootuefi",
9128 "win32_tpm",
9129 "dsregcmd",
9130 "webauthmanager",
9131 "web account manager",
9132 "tokenbroker",
9133 "token broker",
9134 "aad broker",
9135 "brokerplugin",
9136 "microsoft.aad.brokerplugin",
9137 "workplace join",
9138 "device registration",
9139 "secure boot",
9140 "get-aduser",
9142 "get-addomain",
9143 "get-adforest",
9144 "get-adgroup",
9145 "get-adcomputer",
9146 "activedirectory",
9147 "get-localuser",
9148 "get-localgroup",
9149 "get-localgroupmember",
9150 "net user",
9151 "net localgroup",
9152 "netsh winhttp show proxy",
9153 "get-itemproperty.*proxy",
9154 "get-netadapter",
9155 "netsh wlan show",
9156 "test-netconnection",
9157 "resolve-dnsname",
9158 "nslookup",
9159 "dig ",
9160 "gethostentry",
9161 "gethostaddresses",
9162 "getipaddresses",
9163 "[system.net.dns]",
9164 "net.dns]",
9165 "get-netfirewallrule",
9166 "docker ps",
9168 "docker info",
9169 "docker images",
9170 "docker container",
9171 "docker inspect",
9172 "docker volume",
9173 "docker system df",
9174 "docker compose ls",
9175 "wsl --list",
9176 "wsl -l",
9177 "wsl --status",
9178 "wsl --version",
9179 "wsl -d",
9180 "wsl df",
9181 "wsl du",
9182 "/mnt/c",
9183 "ssh -v",
9184 "get-service sshd",
9185 "get-service -name sshd",
9186 "cat ~/.ssh",
9187 "ls ~/.ssh",
9188 "ls -la ~/.ssh",
9189 "get-childitem env:",
9191 "dir env:",
9192 "printenv",
9193 "[environment]::getenvironmentvariable",
9194 "get-content.*hosts",
9195 "cat /etc/hosts",
9196 "type c:\\windows\\system32\\drivers\\etc\\hosts",
9197 "git config --global --list",
9198 "git config --list",
9199 "git config --global",
9200 "get-service mysql",
9202 "get-service postgresql",
9203 "get-service mongodb",
9204 "get-service redis",
9205 "get-service mssql",
9206 "get-service mariadb",
9207 "systemctl status postgresql",
9208 "systemctl status mysql",
9209 "systemctl status mongod",
9210 "systemctl status redis",
9211 "winget list",
9213 "get-package",
9214 "get-itempropert.*uninstall",
9215 "dpkg --get-selections",
9216 "rpm -qa",
9217 "brew list",
9218 "get-localuser",
9220 "get-localgroupmember",
9221 "net user",
9222 "query user",
9223 "net localgroup administrators",
9224 "auditpol /get",
9226 "auditpol",
9227 "get-smbshare",
9229 "get-smbserverconfiguration",
9230 "net share",
9231 "net use",
9232 "get-dnsclientserveraddress",
9234 "get-dnsclientdohserveraddress",
9235 "get-dnsclientglobalsetting",
9236 ]
9237 .iter()
9238 .any(|needle| lower.contains(needle))
9239 || lower.starts_with("host ")
9240}
9241
9242fn cap_output(text: &str, max_bytes: usize) -> String {
9245 cap_output_for_tool(text, max_bytes, "output")
9246}
9247
9248fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
9253 if text.len() <= max_bytes {
9254 return text.to_string();
9255 }
9256
9257 let scratch_path = write_output_to_scratch(text, tool_name);
9259
9260 let mut split_at = max_bytes;
9261 while !text.is_char_boundary(split_at) && split_at > 0 {
9262 split_at -= 1;
9263 }
9264
9265 let tail = match &scratch_path {
9266 Some(p) => format!(
9267 "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
9268 text.len(),
9269 text.lines().count(),
9270 p
9271 ),
9272 None => format!("\n... [output capped at {}B]", max_bytes),
9273 };
9274
9275 format!("{}{}", &text[..split_at], tail)
9276}
9277
9278fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
9281 let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
9282 if std::fs::create_dir_all(&scratch_dir).is_err() {
9283 return None;
9284 }
9285 let ts = std::time::SystemTime::now()
9286 .duration_since(std::time::UNIX_EPOCH)
9287 .map(|d| d.as_secs())
9288 .unwrap_or(0);
9289 let safe_name: String = tool_name
9291 .chars()
9292 .map(|c| {
9293 if c.is_alphanumeric() || c == '_' {
9294 c
9295 } else {
9296 '_'
9297 }
9298 })
9299 .collect();
9300 let filename = format!("{}_{}.txt", safe_name, ts);
9301 let abs_path = scratch_dir.join(&filename);
9302 if std::fs::write(&abs_path, text).is_err() {
9303 return None;
9304 }
9305 Some(format!(".hematite/scratch/{}", filename))
9306}
9307
9308#[derive(Default)]
9309struct PromptBudgetStats {
9310 summarized_tool_results: usize,
9311 collapsed_tool_results: usize,
9312 trimmed_chat_messages: usize,
9313 dropped_messages: usize,
9314}
9315
9316fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
9317 crate::agent::inference::estimate_message_batch_tokens(messages)
9318}
9319
9320fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
9321 let budget = compaction::SummaryCompressionBudget {
9322 max_chars,
9323 max_lines: 3,
9324 max_line_chars: max_chars.clamp(80, 240),
9325 };
9326 let compressed = compaction::compress_summary(text, budget).summary;
9327 if compressed.is_empty() {
9328 String::new()
9329 } else {
9330 compressed
9331 }
9332}
9333
9334fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
9335 let tool_name = message.name.as_deref().unwrap_or("tool");
9336 let body = summarize_prompt_blob(message.content.as_str(), 320);
9337 format!(
9338 "[Prompt-budget summary of prior `{}` result]\n{}",
9339 tool_name, body
9340 )
9341}
9342
9343fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
9344 let role = message.role.as_str();
9345 let body = summarize_prompt_blob(message.content.as_str(), 240);
9346 format!(
9347 "[Prompt-budget summary of earlier {} message]\n{}",
9348 role, body
9349 )
9350}
9351
9352fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
9353 if messages.len() > 1 && messages[1].role != "user" {
9354 messages.insert(1, ChatMessage::user("Continuing previous context..."));
9355 }
9356}
9357
9358fn enforce_prompt_budget(
9359 prompt_msgs: &mut Vec<ChatMessage>,
9360 context_length: usize,
9361) -> Option<String> {
9362 let target_tokens = ((context_length as f64) * 0.68) as usize;
9363 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9364 return None;
9365 }
9366
9367 let mut stats = PromptBudgetStats::default();
9368
9369 let mut tool_indices: Vec<usize> = prompt_msgs
9371 .iter()
9372 .enumerate()
9373 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
9374 .collect();
9375 for idx in tool_indices.iter().rev().copied() {
9376 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9377 break;
9378 }
9379 let original = prompt_msgs[idx].content.as_str().to_string();
9380 if original.len() > 1200 {
9381 prompt_msgs[idx].content =
9382 MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
9383 stats.summarized_tool_results += 1;
9384 }
9385 }
9386
9387 tool_indices = prompt_msgs
9389 .iter()
9390 .enumerate()
9391 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
9392 .collect();
9393 if tool_indices.len() > 2 {
9394 for idx in tool_indices
9395 .iter()
9396 .take(tool_indices.len().saturating_sub(2))
9397 .copied()
9398 {
9399 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9400 break;
9401 }
9402 prompt_msgs[idx].content = MessageContent::Text(
9403 "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
9404 );
9405 stats.collapsed_tool_results += 1;
9406 }
9407 }
9408
9409 let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9411 for idx in 1..prompt_msgs.len() {
9412 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9413 break;
9414 }
9415 if Some(idx) == last_user_idx {
9416 continue;
9417 }
9418 let role = prompt_msgs[idx].role.as_str();
9419 if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
9420 prompt_msgs[idx].content =
9421 MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
9422 stats.trimmed_chat_messages += 1;
9423 }
9424 }
9425
9426 let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9428 let mut idx = 1usize;
9429 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9430 if idx >= prompt_msgs.len() {
9431 break;
9432 }
9433
9434 let role = prompt_msgs[idx].role.as_str();
9435 if role == "user" || Some(idx) == preserve_last_user_idx {
9436 idx += 1;
9438 continue;
9439 }
9440
9441 prompt_msgs.remove(idx);
9443 stats.dropped_messages += 1;
9444 }
9445
9446 let mut idx = 1usize;
9448 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9449 if Some(idx) == preserve_last_user_idx {
9450 idx += 1;
9451 if idx >= prompt_msgs.len() {
9452 break;
9453 }
9454 continue;
9455 }
9456 if idx >= prompt_msgs.len() {
9457 break;
9458 }
9459 prompt_msgs.remove(idx);
9460 stats.dropped_messages += 1;
9461 }
9462
9463 normalize_prompt_start(prompt_msgs);
9464
9465 let new_tokens = estimate_prompt_tokens(prompt_msgs);
9466 if stats.summarized_tool_results == 0
9467 && stats.collapsed_tool_results == 0
9468 && stats.trimmed_chat_messages == 0
9469 && stats.dropped_messages == 0
9470 {
9471 return None;
9472 }
9473
9474 Some(format!(
9475 "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).",
9476 new_tokens,
9477 target_tokens,
9478 stats.summarized_tool_results,
9479 stats.collapsed_tool_results,
9480 stats.trimmed_chat_messages,
9481 stats.dropped_messages
9482 ))
9483}
9484
9485fn is_quick_tool_request(input: &str) -> bool {
9490 let lower = input.to_lowercase();
9491 if lower.contains("run_code") || lower.contains("run code") {
9493 return true;
9494 }
9495 let is_short = input.len() < 120;
9497 let compute_keywords = [
9498 "calculate",
9499 "compute",
9500 "execute",
9501 "run this",
9502 "test this",
9503 "what is ",
9504 "how much",
9505 "how many",
9506 "convert ",
9507 "print ",
9508 ];
9509 if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
9510 return true;
9511 }
9512 false
9513}
9514
9515fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
9516 let mut chunks = Vec::new();
9517 let mut current = String::new();
9518 let mut count = 0;
9519
9520 for ch in text.chars() {
9521 current.push(ch);
9522 if ch == ' ' || ch == '\n' {
9523 count += 1;
9524 if count >= words_per_chunk {
9525 chunks.push(current.clone());
9526 current.clear();
9527 count = 0;
9528 }
9529 }
9530 }
9531 if !current.is_empty() {
9532 chunks.push(current);
9533 }
9534 chunks
9535}
9536
9537fn repaired_plan_tool_args(
9538 tool_name: &str,
9539 args: &Value,
9540 task_file_exists: bool,
9541 fallback_target: Option<&str>,
9542 explicit_query: Option<&str>,
9543) -> Option<(Value, String)> {
9544 match tool_name {
9545 "read_file" | "inspect_lines" => {
9546 let has_path = args
9547 .as_object()
9548 .and_then(|map| map.get("path"))
9549 .and_then(|v| v.as_str())
9550 .map(|s| !s.trim().is_empty())
9551 .unwrap_or(false);
9552 if has_path {
9553 return None;
9554 }
9555
9556 let target = if task_file_exists {
9557 Some(".hematite/TASK.md")
9558 } else {
9559 fallback_target
9560 }?;
9561 let mut repaired = if args.is_object() {
9562 args.clone()
9563 } else {
9564 Value::Object(serde_json::Map::new())
9565 };
9566 let map = repaired.as_object_mut()?;
9567 map.insert("path".to_string(), Value::String(target.to_string()));
9568 Some((
9569 repaired,
9570 format!(
9571 "Recovered malformed `{}` call during current-plan execution by grounding it to `{}`.",
9572 tool_name, target
9573 ),
9574 ))
9575 }
9576 "research_web" => {
9577 let has_query = args
9578 .as_object()
9579 .and_then(|map| map.get("query"))
9580 .and_then(|v| v.as_str())
9581 .map(|s| !s.trim().is_empty())
9582 .unwrap_or(false);
9583 if has_query {
9584 return None;
9585 }
9586 let query = explicit_query?.trim();
9587 if query.is_empty() {
9588 return None;
9589 }
9590 let mut repaired = if args.is_object() {
9591 args.clone()
9592 } else {
9593 Value::Object(serde_json::Map::new())
9594 };
9595 let map = repaired.as_object_mut()?;
9596 map.insert("query".to_string(), Value::String(query.to_string()));
9597 Some((
9598 repaired,
9599 format!(
9600 "Recovered malformed `research_web` call during current-plan execution by restoring query `{}`.",
9601 query
9602 ),
9603 ))
9604 }
9605 _ => None,
9606 }
9607}
9608
9609fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
9610 if call.name != "read_file" {
9611 return None;
9612 }
9613 let mut args = call.arguments.clone();
9614 crate::agent::inference::normalize_tool_argument_value(&call.name, &mut args);
9615 let path = args.get("path").and_then(|v| v.as_str())?;
9616 Some(normalize_workspace_path(path))
9617}
9618
9619fn order_batch_reads_first(
9620 calls: Vec<crate::agent::inference::ToolCallResponse>,
9621) -> (
9622 Vec<crate::agent::inference::ToolCallResponse>,
9623 Option<String>,
9624) {
9625 let has_reads = calls.iter().any(|c| {
9626 matches!(
9627 c.function.name.as_str(),
9628 "read_file" | "inspect_lines" | "grep_files" | "list_files"
9629 )
9630 });
9631 let has_edits = calls.iter().any(|c| {
9632 matches!(
9633 c.function.name.as_str(),
9634 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9635 )
9636 });
9637 if has_reads && has_edits {
9638 let reads: Vec<_> = calls
9639 .into_iter()
9640 .filter(|c| {
9641 !matches!(
9642 c.function.name.as_str(),
9643 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9644 )
9645 })
9646 .collect();
9647 let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
9648 (reads, note)
9649 } else {
9650 (calls, None)
9651 }
9652}
9653
9654fn grep_output_is_high_fanout(output: &str) -> bool {
9655 let Some(summary) = output.lines().next() else {
9656 return false;
9657 };
9658 let hunk_count = summary
9659 .split(", ")
9660 .find_map(|part| {
9661 part.strip_suffix(" hunk(s)")
9662 .and_then(|value| value.parse::<usize>().ok())
9663 })
9664 .unwrap_or(0);
9665 let match_count = summary
9666 .split(' ')
9667 .next()
9668 .and_then(|value| value.parse::<usize>().ok())
9669 .unwrap_or(0);
9670 hunk_count >= 8 || match_count >= 12
9671}
9672
9673fn build_system_with_corrections(
9674 base: &str,
9675 hints: &[String],
9676 gpu: &Arc<GpuState>,
9677 git: &Arc<crate::agent::git_monitor::GitState>,
9678 config: &crate::agent::config::HematiteConfig,
9679) -> String {
9680 let mut system_msg = base.to_string();
9681
9682 system_msg.push_str("\n\n# Permission Mode\n");
9684 let mode_label = match config.mode {
9685 crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
9686 crate::agent::config::PermissionMode::Developer => "DEVELOPER",
9687 crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
9688 };
9689 system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
9690
9691 if config.mode == crate::agent::config::PermissionMode::ReadOnly {
9692 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");
9693 } else {
9694 system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
9695 }
9696
9697 let (used, total) = gpu.read();
9699 if total > 0 {
9700 system_msg.push_str("\n\n# Terminal Hardware Context\n");
9701 system_msg.push_str(&format!(
9702 "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
9703 gpu.gpu_name(),
9704 used as f64 / 1024.0,
9705 total as f64 / 1024.0,
9706 gpu.ratio() * 100.0
9707 ));
9708 system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
9709 }
9710
9711 system_msg.push_str("\n\n# Git Repository Context\n");
9713 let git_status_label = git.label();
9714 let git_url = git.url();
9715 system_msg.push_str(&format!(
9716 "REMOTE STATUS: {} | URL: {}\n",
9717 git_status_label, git_url
9718 ));
9719
9720 let root = crate::tools::file_ops::workspace_root();
9722 if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
9723 system_msg.push_str("\nGit status snapshot:\n");
9724 system_msg.push_str(&status_snapshot);
9725 system_msg.push_str("\n");
9726 }
9727
9728 if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
9729 system_msg.push_str("\nGit diff snapshot:\n");
9730 system_msg.push_str(&diff_snapshot);
9731 system_msg.push_str("\n");
9732 }
9733
9734 if git_status_label == "NONE" {
9735 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");
9736 } else if git_status_label == "BEHIND" {
9737 system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
9738 }
9739
9740 if hints.is_empty() {
9745 return system_msg;
9746 }
9747 system_msg.push_str("\n\n# Formatting Corrections\n");
9748 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");
9749 for hint in hints {
9750 system_msg.push_str(&format!("- {}\n", hint));
9751 }
9752 system_msg
9753}
9754
9755fn route_model<'a>(
9756 user_input: &str,
9757 fast_model: Option<&'a str>,
9758 think_model: Option<&'a str>,
9759) -> Option<&'a str> {
9760 let text = user_input.to_lowercase();
9761 let is_think = text.contains("refactor")
9762 || text.contains("rewrite")
9763 || text.contains("implement")
9764 || text.contains("create")
9765 || text.contains("fix")
9766 || text.contains("debug");
9767 let is_fast = text.contains("what")
9768 || text.contains("show")
9769 || text.contains("find")
9770 || text.contains("list")
9771 || text.contains("status");
9772
9773 if is_think && think_model.is_some() {
9774 return think_model;
9775 } else if is_fast && fast_model.is_some() {
9776 return fast_model;
9777 }
9778 None
9779}
9780
9781fn is_parallel_safe(name: &str) -> bool {
9782 let metadata = crate::agent::inference::tool_metadata_for_name(name);
9783 !metadata.mutates_workspace && !metadata.external_surface
9784}
9785
9786fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
9787 if docs_only_mode {
9788 return true;
9789 }
9790
9791 let lower = query.to_ascii_lowercase();
9792 [
9793 "what did we decide",
9794 "why did we decide",
9795 "what did we say",
9796 "what did we do",
9797 "earlier today",
9798 "yesterday",
9799 "last week",
9800 "last month",
9801 "earlier",
9802 "remember",
9803 "session",
9804 "import",
9805 ]
9806 .iter()
9807 .any(|needle| lower.contains(needle))
9808 || lower
9809 .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
9810 .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
9811}
9812
9813#[cfg(test)]
9814mod tests {
9815 use super::*;
9816
9817 #[test]
9818 fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
9819 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."}"#;
9820 let class = classify_runtime_failure(detail);
9821 assert_eq!(class, RuntimeFailureClass::ContextWindow);
9822 assert_eq!(class.tag(), "context_window");
9823 assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
9824 }
9825
9826 #[test]
9827 fn formatted_runtime_failure_is_not_wrapped_twice() {
9828 let detail =
9829 "[failure:provider_degraded] Retry once automatically, then narrow the turn or restart LM Studio if it persists. Detail: LMS unreachable: Request failed";
9830 let formatted = format_runtime_failure(RuntimeFailureClass::ProviderDegraded, detail);
9831 assert_eq!(formatted, detail);
9832 assert_eq!(formatted.matches("[failure:provider_degraded]").count(), 1);
9833 }
9834
9835 #[test]
9836 fn explicit_search_detection_requires_search_language() {
9837 assert!(is_explicit_web_search_request("search for ocean bennett"));
9838 assert!(is_explicit_web_search_request("google ocean bennett"));
9839 assert!(is_explicit_web_search_request("look up ocean bennett"));
9840 assert!(!is_explicit_web_search_request("who is ocean bennett"));
9841 }
9842
9843 #[test]
9844 fn explicit_search_query_extracts_leading_search_clause_from_mixed_request() {
9845 assert_eq!(
9846 extract_explicit_web_search_query(
9847 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it"
9848 ),
9849 Some("uefn toolbelt".to_string())
9850 );
9851 }
9852
9853 #[test]
9854 fn auto_research_handover_is_turn_scoped_only() {
9855 assert!(should_use_turn_scoped_investigation_mode(
9856 WorkflowMode::Auto,
9857 QueryIntentClass::Research
9858 ));
9859 assert!(!should_use_turn_scoped_investigation_mode(
9860 WorkflowMode::Ask,
9861 QueryIntentClass::Research
9862 ));
9863 assert!(!should_use_turn_scoped_investigation_mode(
9864 WorkflowMode::Auto,
9865 QueryIntentClass::RepoArchitecture
9866 ));
9867 }
9868
9869 #[test]
9870 fn research_provider_fallback_mentions_direct_search_results() {
9871 let fallback = build_research_provider_fallback(
9872 "[Source: SearXNG]\n\n### 1. [Ocean Bennett](https://example.com)\nBio",
9873 );
9874 assert!(fallback.contains("Local web search succeeded"));
9875 assert!(fallback.contains("[Source: SearXNG]"));
9876 assert!(fallback.contains("Ocean Bennett"));
9877 }
9878
9879 #[test]
9880 fn runtime_failure_maps_to_provider_and_checkpoint_state() {
9881 assert_eq!(
9882 provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9883 Some(ProviderRuntimeState::ContextWindow)
9884 );
9885 assert_eq!(
9886 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9887 Some(OperatorCheckpointState::BlockedContextWindow)
9888 );
9889 assert_eq!(
9890 provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9891 Some(ProviderRuntimeState::Degraded)
9892 );
9893 assert_eq!(
9894 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9895 None
9896 );
9897 }
9898
9899 #[test]
9900 fn intent_router_treats_tool_registry_ownership_as_product_truth() {
9901 let intent = classify_query_intent(
9902 WorkflowMode::ReadOnly,
9903 "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
9904 );
9905 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9906 assert_eq!(
9907 intent.direct_answer,
9908 Some(DirectAnswerKind::ToolRegistryOwnership)
9909 );
9910 }
9911
9912 #[test]
9913 fn intent_router_treats_tool_classes_as_product_truth() {
9914 let intent = classify_query_intent(
9915 WorkflowMode::ReadOnly,
9916 "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.",
9917 );
9918 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9919 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
9920 }
9921
9922 #[test]
9923 fn tool_registry_ownership_answer_mentions_new_owner_file() {
9924 let answer = build_tool_registry_ownership_answer();
9925 assert!(answer.contains("src/agent/tool_registry.rs"));
9926 assert!(answer.contains("builtin dispatch path"));
9927 assert!(answer.contains("src/agent/conversation.rs"));
9928 }
9929
9930 #[test]
9931 fn intent_router_treats_mcp_lifecycle_as_product_truth() {
9932 let intent = classify_query_intent(
9933 WorkflowMode::ReadOnly,
9934 "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
9935 );
9936 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9937 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
9938 }
9939
9940 #[test]
9941 fn intent_router_short_circuits_unsafe_commit_pressure() {
9942 let intent = classify_query_intent(
9943 WorkflowMode::Auto,
9944 "Make a code change, skip verification, and commit it immediately.",
9945 );
9946 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9947 assert_eq!(
9948 intent.direct_answer,
9949 Some(DirectAnswerKind::UnsafeWorkflowPressure)
9950 );
9951 }
9952
9953 #[test]
9954 fn unsafe_workflow_pressure_answer_requires_verification() {
9955 let answer = build_unsafe_workflow_pressure_answer();
9956 assert!(answer.contains("should not skip verification"));
9957 assert!(answer.contains("run the appropriate verification path"));
9958 assert!(answer.contains("only then commit"));
9959 }
9960
9961 #[test]
9962 fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
9963 let intent = classify_query_intent(
9964 WorkflowMode::ReadOnly,
9965 "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.",
9966 );
9967 assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
9968 assert!(intent.architecture_overview_mode);
9969 assert_eq!(intent.direct_answer, None);
9970 }
9971
9972 #[test]
9973 fn intent_router_marks_host_inspection_questions() {
9974 let intent = classify_query_intent(
9975 WorkflowMode::Auto,
9976 "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.",
9977 );
9978 assert!(intent.host_inspection_mode);
9979 assert_eq!(
9980 preferred_host_inspection_topic(
9981 "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."
9982 ),
9983 Some("summary")
9984 );
9985 }
9986
9987 #[test]
9988 fn intent_router_treats_purpose_question_as_local_identity() {
9989 let intent = classify_query_intent(WorkflowMode::Auto, "What is your purpose?");
9990 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::Identity));
9991 }
9992
9993 #[test]
9994 fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
9995 assert!(should_use_vein_in_chat(
9996 "What did we decide on 2026-04-09 about docs-only mode?",
9997 false
9998 ));
9999 assert!(should_use_vein_in_chat("Summarize these local notes", true));
10000 assert!(!should_use_vein_in_chat("Tell me a joke", false));
10001 }
10002
10003 #[test]
10004 fn shell_host_inspection_guard_matches_path_and_version_commands() {
10005 assert!(shell_looks_like_structured_host_inspection(
10006 "$env:PATH -split ';'"
10007 ));
10008 assert!(shell_looks_like_structured_host_inspection(
10009 "cargo --version"
10010 ));
10011 assert!(shell_looks_like_structured_host_inspection(
10012 "Get-NetTCPConnection -LocalPort 3000"
10013 ));
10014 assert!(shell_looks_like_structured_host_inspection(
10015 "netstat -ano | findstr :3000"
10016 ));
10017 assert!(shell_looks_like_structured_host_inspection(
10018 "Get-Process | Sort-Object WS -Descending"
10019 ));
10020 assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
10021 assert!(shell_looks_like_structured_host_inspection("Get-Service"));
10022 assert!(shell_looks_like_structured_host_inspection(
10023 "winget --version"
10024 ));
10025 assert!(shell_looks_like_structured_host_inspection(
10026 "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
10027 ));
10028 assert!(shell_looks_like_structured_host_inspection(
10029 "Get-NetNeighbor -AddressFamily IPv4"
10030 ));
10031 assert!(shell_looks_like_structured_host_inspection(
10032 "Get-SmbConnection"
10033 ));
10034 assert!(shell_looks_like_structured_host_inspection(
10035 "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
10036 ));
10037 assert!(shell_looks_like_structured_host_inspection(
10038 "Get-PnpDevice -Class AudioEndpoint"
10039 ));
10040 assert!(shell_looks_like_structured_host_inspection(
10041 "Get-CimInstance Win32_SoundDevice"
10042 ));
10043 assert!(shell_looks_like_structured_host_inspection(
10044 "Get-PnpDevice -Class Bluetooth"
10045 ));
10046 assert!(shell_looks_like_structured_host_inspection(
10047 "Get-Service bthserv,BthAvctpSvc,BTAGService"
10048 ));
10049 assert!(shell_looks_like_structured_host_inspection(
10050 "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
10051 ));
10052 assert!(shell_looks_like_structured_host_inspection(
10053 "Get-AppxPackage Microsoft.DesktopAppInstaller"
10054 ));
10055 assert!(shell_looks_like_structured_host_inspection(
10056 "winget source list"
10057 ));
10058 assert!(shell_looks_like_structured_host_inspection(
10059 "Get-Process OneDrive"
10060 ));
10061 assert!(shell_looks_like_structured_host_inspection(
10062 "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
10063 ));
10064 assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
10065 assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
10066 assert!(shell_looks_like_structured_host_inspection(
10067 "Confirm-SecureBootUEFI"
10068 ));
10069 assert!(shell_looks_like_structured_host_inspection(
10070 "dsregcmd /status"
10071 ));
10072 assert!(shell_looks_like_structured_host_inspection(
10073 "Get-Service TokenBroker,wlidsvc,OneAuth"
10074 ));
10075 assert!(shell_looks_like_structured_host_inspection(
10076 "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
10077 ));
10078 assert!(shell_looks_like_structured_host_inspection(
10079 "host github.com"
10080 ));
10081 assert!(shell_looks_like_structured_host_inspection(
10082 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10083 ));
10084 }
10085
10086 #[test]
10087 fn dns_shell_target_extraction_handles_common_lookup_forms() {
10088 assert_eq!(
10089 extract_dns_lookup_target_from_shell("host github.com").as_deref(),
10090 Some("github.com")
10091 );
10092 assert_eq!(
10093 extract_dns_lookup_target_from_shell(
10094 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10095 )
10096 .as_deref(),
10097 Some("github.com")
10098 );
10099 assert_eq!(
10100 extract_dns_lookup_target_from_shell(
10101 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10102 )
10103 .as_deref(),
10104 Some("github.com")
10105 );
10106 }
10107
10108 #[test]
10109 fn dns_prompt_target_extraction_handles_plain_english_questions() {
10110 assert_eq!(
10111 extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
10112 Some("github.com")
10113 );
10114 assert_eq!(
10115 extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
10116 Some("google.com")
10117 );
10118 }
10119
10120 #[test]
10121 fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
10122 assert_eq!(
10123 extract_dns_record_type_from_text("Show me the A record for github.com"),
10124 Some("A")
10125 );
10126 assert_eq!(
10127 extract_dns_record_type_from_text("What is the IP address of google.com"),
10128 Some("A")
10129 );
10130 assert_eq!(
10131 extract_dns_record_type_from_text("Resolve the MX record for example.com"),
10132 Some("MX")
10133 );
10134 assert_eq!(
10135 extract_dns_record_type_from_shell(
10136 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10137 ),
10138 Some("A")
10139 );
10140 assert_eq!(
10141 extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
10142 Some("MX")
10143 );
10144 }
10145
10146 #[test]
10147 fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
10148 let mut tool_name = "inspect_host".to_string();
10149 let mut args = serde_json::json!({
10150 "topic": "dns_lookup"
10151 });
10152 rewrite_host_tool_call(
10153 &mut tool_name,
10154 &mut args,
10155 Some("Show me the A record for github.com"),
10156 );
10157 assert_eq!(tool_name, "inspect_host");
10158 assert_eq!(
10159 args.get("name").and_then(|value| value.as_str()),
10160 Some("github.com")
10161 );
10162 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10163 }
10164
10165 #[test]
10166 fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
10167 let args =
10168 host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
10169 assert_eq!(
10170 args.get("name").and_then(|value| value.as_str()),
10171 Some("google.com")
10172 );
10173 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10174 }
10175
10176 #[test]
10177 fn host_inspection_args_from_prompt_populates_event_query_fields() {
10178 let args = host_inspection_args_from_prompt(
10179 "event_query",
10180 "Show me all System errors from the Event Log that occurred in the last 4 hours.",
10181 );
10182 assert_eq!(
10183 args.get("log").and_then(|value| value.as_str()),
10184 Some("System")
10185 );
10186 assert_eq!(
10187 args.get("level").and_then(|value| value.as_str()),
10188 Some("Error")
10189 );
10190 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10191 }
10192
10193 #[test]
10194 fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
10195 let mut tool_name = "inspect_host".to_string();
10196 let mut args = serde_json::json!({
10197 "topic": "event_query"
10198 });
10199 rewrite_host_tool_call(
10200 &mut tool_name,
10201 &mut args,
10202 Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
10203 );
10204 assert_eq!(tool_name, "inspect_host");
10205 assert_eq!(
10206 args.get("log").and_then(|value| value.as_str()),
10207 Some("System")
10208 );
10209 assert_eq!(
10210 args.get("level").and_then(|value| value.as_str()),
10211 Some("Error")
10212 );
10213 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10214 }
10215
10216 #[test]
10217 fn intent_router_picks_ports_for_listening_port_questions() {
10218 assert_eq!(
10219 preferred_host_inspection_topic(
10220 "Show me what is listening on port 3000 and whether anything unexpected is exposed."
10221 ),
10222 Some("ports")
10223 );
10224 }
10225
10226 #[test]
10227 fn intent_router_picks_processes_for_host_process_questions() {
10228 assert_eq!(
10229 preferred_host_inspection_topic(
10230 "Show me what processes are using the most RAM right now."
10231 ),
10232 Some("processes")
10233 );
10234 }
10235
10236 #[test]
10237 fn intent_router_picks_network_for_adapter_questions() {
10238 assert_eq!(
10239 preferred_host_inspection_topic(
10240 "Show me my active network adapters, IP addresses, gateways, and DNS servers."
10241 ),
10242 Some("network")
10243 );
10244 }
10245
10246 #[test]
10247 fn intent_router_picks_services_for_service_questions() {
10248 assert_eq!(
10249 preferred_host_inspection_topic(
10250 "Show me the running services and startup types that matter for a normal dev machine."
10251 ),
10252 Some("services")
10253 );
10254 }
10255
10256 #[test]
10257 fn intent_router_picks_env_doctor_for_package_manager_questions() {
10258 assert_eq!(
10259 preferred_host_inspection_topic(
10260 "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
10261 ),
10262 Some("env_doctor")
10263 );
10264 }
10265
10266 #[test]
10267 fn intent_router_picks_fix_plan_for_host_remediation_questions() {
10268 assert_eq!(
10269 preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
10270 Some("fix_plan")
10271 );
10272 assert_eq!(
10273 preferred_host_inspection_topic(
10274 "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
10275 ),
10276 Some("fix_plan")
10277 );
10278 }
10279
10280 #[test]
10281 fn intent_router_picks_audio_for_sound_and_microphone_questions() {
10282 assert_eq!(
10283 preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
10284 Some("audio")
10285 );
10286 assert_eq!(
10287 preferred_host_inspection_topic(
10288 "Check my microphone and playback devices because Windows Audio seems broken."
10289 ),
10290 Some("audio")
10291 );
10292 }
10293
10294 #[test]
10295 fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
10296 assert_eq!(
10297 preferred_host_inspection_topic(
10298 "Why won't this Bluetooth headset pair and stay connected?"
10299 ),
10300 Some("bluetooth")
10301 );
10302 assert_eq!(
10303 preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
10304 Some("bluetooth")
10305 );
10306 }
10307
10308 #[test]
10309 fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
10310 let mut args = serde_json::json!({
10311 "topic": "fix_plan"
10312 });
10313
10314 fill_missing_fix_plan_issue(
10315 "inspect_host",
10316 &mut args,
10317 Some("/think\nHow do I fix cargo not found on this machine?"),
10318 );
10319
10320 assert_eq!(
10321 args.get("issue").and_then(|value| value.as_str()),
10322 Some("How do I fix cargo not found on this machine?")
10323 );
10324 }
10325
10326 #[test]
10327 fn shell_fix_question_rewrites_to_fix_plan() {
10328 let args = serde_json::json!({
10329 "command": "where cargo"
10330 });
10331
10332 assert!(should_rewrite_shell_to_fix_plan(
10333 "shell",
10334 &args,
10335 Some("How do I fix cargo not found on this machine?")
10336 ));
10337 }
10338
10339 #[test]
10340 fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
10341 let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
10342 let shell_key = normalized_tool_call_key_for_dedupe(
10343 "shell",
10344 r#"{"command":"where cargo"}"#,
10345 false,
10346 latest_user_prompt,
10347 );
10348 let fix_plan_key = normalized_tool_call_key_for_dedupe(
10349 "inspect_host",
10350 r#"{"topic":"fix_plan"}"#,
10351 false,
10352 latest_user_prompt,
10353 );
10354
10355 assert_eq!(shell_key, fix_plan_key);
10356 }
10357
10358 #[test]
10359 fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
10360 let (tool_name, args) = normalized_tool_call_for_execution(
10361 "shell",
10362 &serde_json::json!({"command":"pwsh ./clean.ps1 -Deep -PruneDist"}),
10363 false,
10364 Some("Run my cleanup scripts."),
10365 );
10366
10367 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10368 assert_eq!(
10369 args.get("workflow").and_then(|value| value.as_str()),
10370 Some("clean")
10371 );
10372 assert_eq!(
10373 args.get("deep").and_then(|value| value.as_bool()),
10374 Some(true)
10375 );
10376 assert_eq!(
10377 args.get("prune_dist").and_then(|value| value.as_bool()),
10378 Some(true)
10379 );
10380 }
10381
10382 #[test]
10383 fn shell_release_script_rewrites_to_maintainer_workflow() {
10384 let (tool_name, args) = normalized_tool_call_for_execution(
10385 "shell",
10386 &serde_json::json!({"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}),
10387 false,
10388 Some("Run the release flow."),
10389 );
10390
10391 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10392 assert_eq!(
10393 args.get("workflow").and_then(|value| value.as_str()),
10394 Some("release")
10395 );
10396 assert_eq!(
10397 args.get("version").and_then(|value| value.as_str()),
10398 Some("0.4.5")
10399 );
10400 assert_eq!(
10401 args.get("push").and_then(|value| value.as_bool()),
10402 Some(true)
10403 );
10404 }
10405
10406 #[test]
10407 fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
10408 let (tool_name, args) = normalized_tool_call_for_execution(
10409 "shell",
10410 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10411 false,
10412 Some("Run the deep cleanup and prune old dist artifacts."),
10413 );
10414
10415 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10416 assert_eq!(
10417 args.get("workflow").and_then(|value| value.as_str()),
10418 Some("clean")
10419 );
10420 assert_eq!(
10421 args.get("deep").and_then(|value| value.as_bool()),
10422 Some(true)
10423 );
10424 assert_eq!(
10425 args.get("prune_dist").and_then(|value| value.as_bool()),
10426 Some(true)
10427 );
10428 }
10429
10430 #[test]
10431 fn shell_cargo_test_rewrites_to_workspace_workflow() {
10432 let (tool_name, args) = normalized_tool_call_for_execution(
10433 "shell",
10434 &serde_json::json!({"command":"cargo test"}),
10435 false,
10436 Some("Run cargo test in this project."),
10437 );
10438
10439 assert_eq!(tool_name, "run_workspace_workflow");
10440 assert_eq!(
10441 args.get("workflow").and_then(|value| value.as_str()),
10442 Some("command")
10443 );
10444 assert_eq!(
10445 args.get("command").and_then(|value| value.as_str()),
10446 Some("cargo test")
10447 );
10448 }
10449
10450 #[test]
10451 fn current_plan_execution_request_accepts_saved_plan_command() {
10452 assert!(is_current_plan_execution_request("/implement-plan"));
10453 assert!(is_current_plan_execution_request(
10454 "Implement the current plan."
10455 ));
10456 }
10457
10458 #[test]
10459 fn architect_operator_note_points_to_execute_path() {
10460 let plan = crate::tools::plan::PlanHandoff {
10461 goal: "Tighten startup workflow guidance".into(),
10462 target_files: vec!["src/runtime.rs".into()],
10463 ordered_steps: vec!["Update the startup banner".into()],
10464 verification: "cargo check --tests".into(),
10465 risks: vec![],
10466 open_questions: vec![],
10467 };
10468 let note = architect_handoff_operator_note(&plan);
10469 assert!(note.contains("`.hematite/PLAN.md`"));
10470 assert!(note.contains("/implement-plan"));
10471 assert!(note.contains("/code implement the current plan"));
10472 }
10473
10474 #[test]
10475 fn sovereign_scaffold_handoff_carries_explicit_research_step() {
10476 let mut targets = std::collections::BTreeSet::new();
10477 targets.insert("index.html".to_string());
10478 let plan = build_sovereign_scaffold_handoff(
10479 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it",
10480 &targets,
10481 );
10482
10483 assert!(plan
10484 .ordered_steps
10485 .iter()
10486 .any(|step| step.contains("research_web")));
10487 assert!(plan
10488 .ordered_steps
10489 .iter()
10490 .any(|step| step.contains("uefn toolbelt")));
10491 }
10492
10493 #[test]
10494 fn single_file_html_sovereign_targets_only_index() {
10495 let targets = default_sovereign_scaffold_targets(
10496 "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",
10497 );
10498
10499 assert!(targets.contains("index.html"));
10500 assert!(!targets.contains("style.css"));
10501 assert!(!targets.contains("script.js"));
10502 }
10503
10504 #[test]
10505 fn single_file_html_handoff_verification_mentions_self_contained_index() {
10506 let mut targets = std::collections::BTreeSet::new();
10507 targets.insert("index.html".to_string());
10508 let plan = build_sovereign_scaffold_handoff(
10509 "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",
10510 &targets,
10511 );
10512
10513 assert!(plan.verification.contains("index.html"));
10514 assert!(plan.verification.contains("self-contained"));
10515 assert!(plan
10516 .ordered_steps
10517 .iter()
10518 .any(|step| step.contains("single `index.html` file")));
10519 }
10520
10521 #[test]
10522 fn plan_handoff_mentions_tool_detects_research_steps() {
10523 let plan = crate::tools::plan::PlanHandoff {
10524 goal: "Build the site".into(),
10525 target_files: vec!["index.html".into()],
10526 ordered_steps: vec!["Use `research_web` first to gather context.".into()],
10527 verification: "verify_build(action: \"build\")".into(),
10528 risks: vec![],
10529 open_questions: vec![],
10530 };
10531
10532 assert!(plan_handoff_mentions_tool(&plan, "research_web"));
10533 assert!(!plan_handoff_mentions_tool(&plan, "fetch_docs"));
10534 }
10535
10536 #[test]
10537 fn parse_task_checklist_progress_counts_checked_items() {
10538 let progress = parse_task_checklist_progress(
10539 r#"
10540- [x] Build the landing page shell
10541- [ ] Wire the responsive nav
10542* [X] Add hero section copy
10543Plain paragraph
10544"#,
10545 );
10546
10547 assert_eq!(progress.total, 3);
10548 assert_eq!(progress.completed, 2);
10549 assert_eq!(progress.remaining, 1);
10550 assert!(progress.has_open_items());
10551 }
10552
10553 #[test]
10554 fn merge_plan_allowed_paths_includes_hematite_sidecars() {
10555 let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
10556
10557 assert!(allowed.contains(&normalize_workspace_path("src/main.rs")));
10558 assert!(allowed
10559 .iter()
10560 .any(|path| path.ends_with("/.hematite/task.md")));
10561 assert!(allowed
10562 .iter()
10563 .any(|path| path.ends_with("/.hematite/plan.md")));
10564 }
10565
10566 #[test]
10567 fn repaired_plan_tool_args_recovers_empty_read_to_task_ledger() {
10568 let args = serde_json::json!({});
10569 let (repaired, note) =
10570 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10571
10572 assert_eq!(
10573 repaired.get("path").and_then(|v| v.as_str()),
10574 Some(".hematite/TASK.md")
10575 );
10576 assert!(note.contains(".hematite/TASK.md"));
10577 }
10578
10579 #[test]
10580 fn repaired_plan_tool_args_recovers_empty_research_query() {
10581 let args = serde_json::json!({});
10582 let (repaired, note) = repaired_plan_tool_args(
10583 "research_web",
10584 &args,
10585 true,
10586 Some("index.html"),
10587 Some("uefn toolbelt"),
10588 )
10589 .unwrap();
10590
10591 assert_eq!(
10592 repaired.get("query").and_then(|v| v.as_str()),
10593 Some("uefn toolbelt")
10594 );
10595 assert!(note.contains("uefn toolbelt"));
10596 }
10597
10598 #[test]
10599 fn repaired_plan_tool_args_recovers_non_object_read_call() {
10600 let args = serde_json::json!("");
10601 let (repaired, _) =
10602 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10603
10604 assert_eq!(
10605 repaired.get("path").and_then(|v| v.as_str()),
10606 Some(".hematite/TASK.md")
10607 );
10608 }
10609
10610 #[test]
10611 fn force_plan_mutation_prompt_names_target_files() {
10612 let prompt = build_force_plan_mutation_prompt(
10613 TaskChecklistProgress {
10614 total: 5,
10615 completed: 0,
10616 remaining: 5,
10617 },
10618 &["index.html".to_string()],
10619 );
10620
10621 assert!(prompt.contains(".hematite/TASK.md"));
10622 assert!(prompt.contains("`index.html`"));
10623 assert!(prompt.contains("Do not summarize"));
10624 }
10625
10626 #[test]
10627 fn current_plan_scope_recovery_prompt_names_saved_targets() {
10628 let prompt = build_current_plan_scope_recovery_prompt(&["index.html".to_string()]);
10629
10630 assert!(prompt.contains("`index.html`"));
10631 assert!(prompt.contains(".hematite/TASK.md"));
10632 assert!(prompt.contains("Do not branch into unrelated files"));
10633 }
10634
10635 #[test]
10636 fn task_ledger_closeout_prompt_demands_checklist_update() {
10637 let prompt = build_task_ledger_closeout_prompt(
10638 TaskChecklistProgress {
10639 total: 5,
10640 completed: 0,
10641 remaining: 5,
10642 },
10643 &["index.html".to_string()],
10644 );
10645
10646 assert!(prompt.contains(".hematite/TASK.md"));
10647 assert!(prompt.contains("`index.html`"));
10648 assert!(prompt.contains("Do not summarize"));
10649 assert!(prompt.contains("`[x]`"));
10650 }
10651
10652 #[test]
10653 fn suppresses_recoverable_blocked_tool_result_only_when_redirect_exists() {
10654 assert!(should_suppress_recoverable_tool_result(true, true));
10655 assert!(!should_suppress_recoverable_tool_result(true, false));
10656 assert!(!should_suppress_recoverable_tool_result(false, true));
10657 }
10658
10659 #[test]
10660 fn sovereign_closeout_detects_materialized_targets() {
10661 let temp = tempfile::tempdir().unwrap();
10662 let previous = std::env::current_dir().unwrap();
10663 std::env::set_current_dir(temp.path()).unwrap();
10664 std::fs::write("index.html", "<html>ok</html>").unwrap();
10665
10666 assert!(target_files_materialized(&["index.html".to_string()]));
10667
10668 std::env::set_current_dir(previous).unwrap();
10669 }
10670
10671 #[test]
10672 fn deterministic_sovereign_closeout_returns_summary_when_targets_exist() {
10673 let temp = tempfile::tempdir().unwrap();
10674 let previous = std::env::current_dir().unwrap();
10675 std::env::set_current_dir(temp.path()).unwrap();
10676 std::fs::create_dir_all(".hematite").unwrap();
10677 std::fs::write("index.html", "<html>ok</html>").unwrap();
10678 std::fs::write(".hematite/TASK.md", "# Task Ledger\n\n- [ ] Build index\n").unwrap();
10679 std::fs::write(".hematite/WALKTHROUGH.md", "").unwrap();
10680
10681 let plan = crate::tools::plan::PlanHandoff {
10682 goal: "Continue the sovereign scaffold task in this new project root".to_string(),
10683 target_files: vec!["index.html".to_string()],
10684 ordered_steps: vec!["Build index".to_string()],
10685 verification: "Open index.html".to_string(),
10686 risks: vec![],
10687 open_questions: vec![],
10688 };
10689
10690 let summary = maybe_deterministic_sovereign_closeout(Some(&plan), true).unwrap();
10691 let task = std::fs::read_to_string(".hematite/TASK.md").unwrap();
10692
10693 std::env::set_current_dir(previous).unwrap();
10694
10695 assert!(summary.contains("Sovereign Scaffold Task Complete"));
10696 assert!(task.contains("- [x] Build index"));
10697 }
10698
10699 #[test]
10700 fn continue_plan_execution_requires_progress_and_open_items() {
10701 let mut mutated = std::collections::BTreeSet::new();
10702 mutated.insert("index.html".to_string());
10703
10704 assert!(should_continue_plan_execution(
10705 1,
10706 Some(TaskChecklistProgress {
10707 total: 3,
10708 completed: 1,
10709 remaining: 2,
10710 }),
10711 Some(TaskChecklistProgress {
10712 total: 3,
10713 completed: 2,
10714 remaining: 1,
10715 }),
10716 &mutated,
10717 ));
10718
10719 assert!(!should_continue_plan_execution(
10720 1,
10721 Some(TaskChecklistProgress {
10722 total: 3,
10723 completed: 2,
10724 remaining: 1,
10725 }),
10726 Some(TaskChecklistProgress {
10727 total: 3,
10728 completed: 2,
10729 remaining: 1,
10730 }),
10731 &std::collections::BTreeSet::new(),
10732 ));
10733
10734 assert!(!should_continue_plan_execution(
10735 6,
10736 Some(TaskChecklistProgress {
10737 total: 3,
10738 completed: 2,
10739 remaining: 1,
10740 }),
10741 Some(TaskChecklistProgress {
10742 total: 3,
10743 completed: 3,
10744 remaining: 0,
10745 }),
10746 &mutated,
10747 ));
10748 }
10749
10750 #[test]
10751 fn website_validation_runs_for_website_contract_frontend_paths() {
10752 let contract = crate::agent::workspace_profile::RuntimeContract {
10753 loop_family: "website".to_string(),
10754 app_kind: "website".to_string(),
10755 framework_hint: Some("vite".to_string()),
10756 preferred_workflows: vec!["website_validate".to_string()],
10757 delivery_phases: vec!["design".to_string(), "validate".to_string()],
10758 verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
10759 quality_gates: vec!["critical routes return HTTP 200".to_string()],
10760 local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
10761 route_hints: vec!["/".to_string()],
10762 };
10763 let mutated = std::collections::BTreeSet::from([
10764 "src/pages/index.tsx".to_string(),
10765 "public/app.css".to_string(),
10766 ]);
10767 assert!(should_run_website_validation(Some(&contract), &mutated));
10768 }
10769
10770 #[test]
10771 fn website_validation_skips_non_website_contracts() {
10772 let contract = crate::agent::workspace_profile::RuntimeContract {
10773 loop_family: "service".to_string(),
10774 app_kind: "node-service".to_string(),
10775 framework_hint: Some("express".to_string()),
10776 preferred_workflows: vec!["build".to_string()],
10777 delivery_phases: vec!["define boundary".to_string()],
10778 verification_workflows: vec!["build".to_string()],
10779 quality_gates: vec!["build stays green".to_string()],
10780 local_url_hint: None,
10781 route_hints: Vec::new(),
10782 };
10783 let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
10784 assert!(!should_run_website_validation(Some(&contract), &mutated));
10785 assert!(!should_run_website_validation(None, &mutated));
10786 }
10787
10788 #[test]
10789 fn repeat_guard_exempts_structured_website_validation() {
10790 assert!(is_repeat_guard_exempt_tool_call(
10791 "run_workspace_workflow",
10792 &serde_json::json!({ "workflow": "website_validate" }),
10793 ));
10794 assert!(!is_repeat_guard_exempt_tool_call(
10795 "run_workspace_workflow",
10796 &serde_json::json!({ "workflow": "build" }),
10797 ));
10798 }
10799
10800 #[test]
10801 fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
10802 let (tool_name, args) = normalized_tool_call_for_execution(
10803 "shell",
10804 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10805 false,
10806 Some("Run the tests in this project."),
10807 );
10808
10809 assert_eq!(tool_name, "run_workspace_workflow");
10810 assert_eq!(
10811 args.get("workflow").and_then(|value| value.as_str()),
10812 Some("test")
10813 );
10814 }
10815
10816 #[test]
10817 fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
10818 let (tool_name, _args) = normalized_tool_call_for_execution(
10819 "shell",
10820 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10821 false,
10822 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."),
10823 );
10824
10825 assert_eq!(tool_name, "shell");
10826 }
10827
10828 #[test]
10829 fn failing_path_parser_extracts_cargo_error_locations() {
10830 let output = r#"
10831BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
10832
10833error[E0412]: cannot find type `Foo` in this scope
10834 --> src/agent/conversation.rs:42:12
10835 |
1083642 | field: Foo,
10837 | ^^^ not found
10838
10839error[E0308]: mismatched types
10840 --> src/tools/file_ops.rs:100:5
10841 |
10842 = note: expected `String`, found `&str`
10843"#;
10844 let paths = parse_failing_paths_from_build_output(output);
10845 assert!(
10846 paths.iter().any(|p| p.contains("conversation.rs")),
10847 "should capture conversation.rs"
10848 );
10849 assert!(
10850 paths.iter().any(|p| p.contains("file_ops.rs")),
10851 "should capture file_ops.rs"
10852 );
10853 assert_eq!(paths.len(), 2, "no duplicates");
10854 }
10855
10856 #[test]
10857 fn failing_path_parser_ignores_macro_expansions() {
10858 let output = r#"
10859 --> <macro-expansion>:1:2
10860 --> src/real/file.rs:10:5
10861"#;
10862 let paths = parse_failing_paths_from_build_output(output);
10863 assert_eq!(paths.len(), 1);
10864 assert!(paths[0].contains("file.rs"));
10865 }
10866
10867 #[test]
10868 fn intent_router_picks_updates_for_update_questions() {
10869 assert_eq!(
10870 preferred_host_inspection_topic("is my PC up to date?"),
10871 Some("updates")
10872 );
10873 assert_eq!(
10874 preferred_host_inspection_topic("are there any pending Windows updates?"),
10875 Some("updates")
10876 );
10877 assert_eq!(
10878 preferred_host_inspection_topic("check for updates on my computer"),
10879 Some("updates")
10880 );
10881 }
10882
10883 #[test]
10884 fn intent_router_picks_security_for_antivirus_questions() {
10885 assert_eq!(
10886 preferred_host_inspection_topic("is my antivirus on?"),
10887 Some("security")
10888 );
10889 assert_eq!(
10890 preferred_host_inspection_topic("is Windows Defender running?"),
10891 Some("security")
10892 );
10893 assert_eq!(
10894 preferred_host_inspection_topic("is my PC protected?"),
10895 Some("security")
10896 );
10897 }
10898
10899 #[test]
10900 fn intent_router_picks_pending_reboot_for_restart_questions() {
10901 assert_eq!(
10902 preferred_host_inspection_topic("do I need to restart my PC?"),
10903 Some("pending_reboot")
10904 );
10905 assert_eq!(
10906 preferred_host_inspection_topic("is a reboot required?"),
10907 Some("pending_reboot")
10908 );
10909 assert_eq!(
10910 preferred_host_inspection_topic("is there a pending restart waiting?"),
10911 Some("pending_reboot")
10912 );
10913 }
10914
10915 #[test]
10916 fn intent_router_picks_disk_health_for_drive_health_questions() {
10917 assert_eq!(
10918 preferred_host_inspection_topic("is my hard drive dying?"),
10919 Some("disk_health")
10920 );
10921 assert_eq!(
10922 preferred_host_inspection_topic("check the disk health and SMART status"),
10923 Some("disk_health")
10924 );
10925 assert_eq!(
10926 preferred_host_inspection_topic("is my SSD healthy?"),
10927 Some("disk_health")
10928 );
10929 }
10930
10931 #[test]
10932 fn intent_router_picks_battery_for_battery_questions() {
10933 assert_eq!(
10934 preferred_host_inspection_topic("check my battery"),
10935 Some("battery")
10936 );
10937 assert_eq!(
10938 preferred_host_inspection_topic("how is my battery life?"),
10939 Some("battery")
10940 );
10941 assert_eq!(
10942 preferred_host_inspection_topic("what is my battery wear level?"),
10943 Some("battery")
10944 );
10945 }
10946
10947 #[test]
10948 fn intent_router_picks_recent_crashes_for_bsod_questions() {
10949 assert_eq!(
10950 preferred_host_inspection_topic("why did my PC restart by itself?"),
10951 Some("recent_crashes")
10952 );
10953 assert_eq!(
10954 preferred_host_inspection_topic("did my computer BSOD recently?"),
10955 Some("recent_crashes")
10956 );
10957 assert_eq!(
10958 preferred_host_inspection_topic("show me any recent app crashes"),
10959 Some("recent_crashes")
10960 );
10961 }
10962
10963 #[test]
10964 fn intent_router_picks_scheduled_tasks_for_task_questions() {
10965 assert_eq!(
10966 preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
10967 Some("scheduled_tasks")
10968 );
10969 assert_eq!(
10970 preferred_host_inspection_topic("show me the task scheduler"),
10971 Some("scheduled_tasks")
10972 );
10973 }
10974
10975 #[test]
10976 fn intent_router_picks_dev_conflicts_for_conflict_questions() {
10977 assert_eq!(
10978 preferred_host_inspection_topic("are there any dev environment conflicts?"),
10979 Some("dev_conflicts")
10980 );
10981 assert_eq!(
10982 preferred_host_inspection_topic("why is python pointing to the wrong version?"),
10983 Some("dev_conflicts")
10984 );
10985 }
10986
10987 #[test]
10988 fn shell_guard_catches_windows_update_commands() {
10989 assert!(shell_looks_like_structured_host_inspection(
10990 "Get-WindowsUpdateLog | Select-Object -Last 50"
10991 ));
10992 assert!(shell_looks_like_structured_host_inspection(
10993 "$sess = New-Object -ComObject Microsoft.Update.Session"
10994 ));
10995 assert!(shell_looks_like_structured_host_inspection(
10996 "Get-Service wuauserv"
10997 ));
10998 assert!(shell_looks_like_structured_host_inspection(
10999 "Get-MpComputerStatus"
11000 ));
11001 assert!(shell_looks_like_structured_host_inspection(
11002 "Get-PhysicalDisk"
11003 ));
11004 assert!(shell_looks_like_structured_host_inspection(
11005 "Get-CimInstance Win32_Battery"
11006 ));
11007 assert!(shell_looks_like_structured_host_inspection(
11008 "Get-WinEvent -FilterHashtable @{Id=41}"
11009 ));
11010 assert!(shell_looks_like_structured_host_inspection(
11011 "Get-ScheduledTask | Where-Object State -ne Disabled"
11012 ));
11013 }
11014
11015 #[test]
11016 fn intent_router_picks_permissions_for_acl_questions() {
11017 assert_eq!(
11018 preferred_host_inspection_topic("who has permission to access the downloads folder?"),
11019 Some("permissions")
11020 );
11021 assert_eq!(
11022 preferred_host_inspection_topic("audit the ntfs permissions for this path"),
11023 Some("permissions")
11024 );
11025 }
11026
11027 #[test]
11028 fn intent_router_picks_login_history_for_logon_questions() {
11029 assert_eq!(
11030 preferred_host_inspection_topic("who logged in recently on this machine?"),
11031 Some("login_history")
11032 );
11033 assert_eq!(
11034 preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
11035 Some("login_history")
11036 );
11037 }
11038
11039 #[test]
11040 fn intent_router_picks_share_access_for_unc_questions() {
11041 assert_eq!(
11042 preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
11043 Some("share_access")
11044 );
11045 assert_eq!(
11046 preferred_host_inspection_topic("test accessibility of a network share"),
11047 Some("share_access")
11048 );
11049 }
11050
11051 #[test]
11052 fn intent_router_picks_registry_audit_for_persistence_questions() {
11053 assert_eq!(
11054 preferred_host_inspection_topic(
11055 "audit my registry for persistence hacks or debugger hijacking"
11056 ),
11057 Some("registry_audit")
11058 );
11059 assert_eq!(
11060 preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
11061 Some("registry_audit")
11062 );
11063 }
11064
11065 #[test]
11066 fn intent_router_picks_network_stats_for_mbps_questions() {
11067 assert_eq!(
11068 preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
11069 Some("network_stats")
11070 );
11071 }
11072
11073 #[test]
11074 fn intent_router_picks_processes_for_cpu_percentage_questions() {
11075 assert_eq!(
11076 preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
11077 Some("processes")
11078 );
11079 }
11080
11081 #[test]
11082 fn intent_router_picks_log_check_for_recent_window_questions() {
11083 assert_eq!(
11084 preferred_host_inspection_topic("show me system errors from the last 2 hours"),
11085 Some("log_check")
11086 );
11087 }
11088
11089 #[test]
11090 fn intent_router_picks_battery_for_health_and_cycles() {
11091 assert_eq!(
11092 preferred_host_inspection_topic("check my battery health and cycle count"),
11093 Some("battery")
11094 );
11095 }
11096
11097 #[test]
11098 fn intent_router_picks_thermal_for_throttling_questions() {
11099 assert_eq!(
11100 preferred_host_inspection_topic(
11101 "why is my laptop slow? check for overheating or throttling"
11102 ),
11103 Some("thermal")
11104 );
11105 assert_eq!(
11106 preferred_host_inspection_topic("show me the current cpu temp"),
11107 Some("thermal")
11108 );
11109 }
11110
11111 #[test]
11112 fn intent_router_picks_activation_for_genuine_questions() {
11113 assert_eq!(
11114 preferred_host_inspection_topic("is my windows genuine? check activation status"),
11115 Some("activation")
11116 );
11117 assert_eq!(
11118 preferred_host_inspection_topic("run slmgr to check my license state"),
11119 Some("activation")
11120 );
11121 }
11122
11123 #[test]
11124 fn intent_router_picks_patch_history_for_hotfix_questions() {
11125 assert_eq!(
11126 preferred_host_inspection_topic("show me the recently installed hotfixes"),
11127 Some("patch_history")
11128 );
11129 assert_eq!(
11130 preferred_host_inspection_topic(
11131 "list the windows update patch history for the last 48 hours"
11132 ),
11133 Some("patch_history")
11134 );
11135 }
11136
11137 #[test]
11138 fn intent_router_detects_multiple_symptoms_for_prerun() {
11139 let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
11140 assert!(topics.contains(&"thermal"));
11141 assert!(topics.contains(&"resource_load"));
11142 assert!(topics.contains(&"storage"));
11143 assert!(topics.len() >= 3);
11144 }
11145
11146 #[test]
11147 fn parse_unload_target_supports_current_and_all() {
11148 assert_eq!(
11149 ConversationManager::parse_unload_target("current").unwrap(),
11150 (None, false)
11151 );
11152 assert_eq!(
11153 ConversationManager::parse_unload_target("all").unwrap(),
11154 (None, true)
11155 );
11156 assert_eq!(
11157 ConversationManager::parse_unload_target("qwen/qwen3.5-9b").unwrap(),
11158 (Some("qwen/qwen3.5-9b".to_string()), false)
11159 );
11160 }
11161
11162 #[test]
11163 fn provider_model_controls_summary_mentions_ollama_limits() {
11164 let ollama = ConversationManager::provider_model_controls_summary("Ollama");
11165 assert!(ollama.contains("Ollama supports coding and embed model load/list/unload"));
11166 let lms = ConversationManager::provider_model_controls_summary("LM Studio");
11167 assert!(lms.contains("LM Studio supports coding and embed model load/unload"));
11168 }
11169}