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