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 _ = crate::tools::file_ops::safe_write(&path, json.as_bytes());
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 _ = crate::tools::file_ops::safe_write(&path, json.as_bytes());
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.starts_with("/query") {
3110 let q = user_input.strip_prefix("/query").unwrap_or("").trim();
3111 if q.is_empty() {
3112 for chunk in chunk_text(
3113 "Usage: /query <natural language question>\n\
3114 Example: /query why is my PC slow\n\
3115 Routes your question to the right inspect_host topics and runs them without a model.",
3116 8,
3117 ) {
3118 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3119 }
3120 let _ = tx.send(InferenceEvent::Done).await;
3121 return Ok(());
3122 }
3123 let detected = all_host_inspection_topics(q);
3124 let topics: Vec<&str> = if !detected.is_empty() {
3125 detected
3126 } else {
3127 match preferred_host_inspection_topic(q) {
3128 Some(t) => vec![t],
3129 None => vec!["summary"],
3130 }
3131 };
3132 let _ = tx
3133 .send(InferenceEvent::Thought(format!(
3134 "Query routed to {} topic(s): {}",
3135 topics.len(),
3136 topics.join(", ")
3137 )))
3138 .await;
3139 let total = topics.len();
3140 let mut out = String::new();
3141 for (i, topic) in topics.iter().enumerate() {
3142 let _ = tx
3143 .send(InferenceEvent::Thought(format!(
3144 " [{}/{}] inspecting {}...",
3145 i + 1,
3146 total,
3147 topic
3148 )))
3149 .await;
3150 let args = serde_json::json!({"topic": topic});
3151 let result = inspect_host(&args)
3152 .await
3153 .unwrap_or_else(|e| format!("Error: {}", e));
3154 if total > 1 {
3155 out.push_str(&format!("─── {} ───\n", topic));
3156 }
3157 out.push_str(result.trim_end());
3158 out.push('\n');
3159 if total > 1 {
3160 out.push('\n');
3161 }
3162 }
3163 for chunk in chunk_text(&out, 8) {
3164 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3165 }
3166 let _ = tx.send(InferenceEvent::Done).await;
3167 return Ok(());
3168 }
3169
3170 if user_input.trim() == "/new" {
3172 self.history.clear();
3173 self.reasoning_history = None;
3174 self.session_memory.clear();
3175 self.running_summary = None;
3176 self.correction_hints.clear();
3177 self.pinned_files.write().await.clear();
3178 self.reset_action_grounding().await;
3179 reset_task_files();
3180 let _ = std::fs::remove_file(session_path());
3181 self.save_empty_session();
3182 self.emit_compaction_pressure(&tx).await;
3183 self.emit_prompt_pressure_idle(&tx).await;
3184 for chunk in chunk_text(
3185 "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
3186 8,
3187 ) {
3188 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3189 }
3190 let _ = tx.send(InferenceEvent::Done).await;
3191 return Ok(());
3192 }
3193
3194 if user_input.trim() == "/forget" {
3195 self.history.clear();
3196 self.reasoning_history = None;
3197 self.session_memory.clear();
3198 self.running_summary = None;
3199 self.correction_hints.clear();
3200 self.pinned_files.write().await.clear();
3201 self.reset_action_grounding().await;
3202 reset_task_files();
3203 crate::agent::tasks::clear();
3204 purge_persistent_memory();
3205 tokio::task::block_in_place(|| self.vein.reset());
3206 let _ = std::fs::remove_file(session_path());
3207 self.save_empty_session();
3208 self.emit_compaction_pressure(&tx).await;
3209 self.emit_prompt_pressure_idle(&tx).await;
3210 for chunk in chunk_text(
3211 "Hard forget complete. Chat history, saved memory, task files, task list, and the Vein index were purged.",
3212 8,
3213 ) {
3214 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3215 }
3216 let _ = tx.send(InferenceEvent::Done).await;
3217 return Ok(());
3218 }
3219
3220 if user_input.trim() == "/vein-inspect" {
3221 let indexed = self.refresh_vein_index();
3222 let report = self.build_vein_inspection_report(indexed);
3223 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
3224 let _ = tx
3225 .send(InferenceEvent::VeinStatus {
3226 file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
3227 embedded_count: snapshot.embedded_source_doc_chunks,
3228 docs_only: self.vein_docs_only_mode(),
3229 })
3230 .await;
3231 for chunk in chunk_text(&report, 8) {
3232 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3233 }
3234 let _ = tx.send(InferenceEvent::Done).await;
3235 return Ok(());
3236 }
3237
3238 if user_input.trim() == "/workspace-profile" {
3239 let root = crate::tools::file_ops::workspace_root();
3240 let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
3241 let report = crate::agent::workspace_profile::profile_report(&root);
3242 for chunk in chunk_text(&report, 8) {
3243 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3244 }
3245 let _ = tx.send(InferenceEvent::Done).await;
3246 return Ok(());
3247 }
3248
3249 if user_input.trim() == "/rules" {
3250 let workspace_root = crate::tools::file_ops::workspace_root();
3251 let report = {
3252 let mut combined = String::with_capacity(
3253 crate::agent::instructions::PROJECT_GUIDANCE_FILES.len() * 512,
3254 );
3255 for name in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
3256 let path =
3257 crate::agent::instructions::resolve_guidance_path(&workspace_root, name);
3258 if !path.exists() {
3259 continue;
3260 }
3261 match std::fs::read_to_string(&path) {
3262 Ok(content) => {
3263 let _ = write!(combined, "## {}\n\n{}\n\n", name, content.trim());
3264 }
3265 Err(e) => {
3266 let _ = write!(
3267 combined,
3268 "## {}\n\nError reading {}: {}\n\n",
3269 name,
3270 path.display(),
3271 e
3272 );
3273 }
3274 }
3275 }
3276 if combined.is_empty() {
3277 "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()
3278 } else {
3279 format!(
3280 "## 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.",
3281 combined
3282 )
3283 }
3284 };
3285 for chunk in chunk_text(&report, 8) {
3286 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3287 }
3288 let _ = tx.send(InferenceEvent::Done).await;
3289 return Ok(());
3290 }
3291
3292 if user_input.trim() == "/skills" {
3293 let workspace_root = crate::tools::file_ops::workspace_root();
3294 let config = crate::agent::config::load_config();
3295 let discovery =
3296 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3297 let report = crate::agent::instructions::render_skills_report(&discovery);
3298 for chunk in chunk_text(&report, 8) {
3299 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3300 }
3301 let _ = tx.send(InferenceEvent::Done).await;
3302 return Ok(());
3303 }
3304
3305 if let Some(skill_name) = user_input
3307 .trim()
3308 .strip_prefix("/skill ")
3309 .map(str::trim)
3310 .filter(|s| !s.is_empty())
3311 {
3312 let workspace_root = crate::tools::file_ops::workspace_root();
3313 let config = crate::agent::config::load_config();
3314 let discovery =
3315 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
3316 let name_lower = skill_name.to_lowercase();
3317 if let Some(skill) = discovery
3318 .skills
3319 .iter()
3320 .find(|s| s.name.to_lowercase() == name_lower)
3321 {
3322 if skill.body.is_empty() {
3323 let msg = format!(
3324 "Skill `{}` found but its SKILL.md has no body — add instructions after the frontmatter.",
3325 skill.name
3326 );
3327 for chunk in chunk_text(&msg, 8) {
3328 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3329 }
3330 } else {
3331 self.pending_skill_inject =
3332 Some(format!("## Skill: {}\n{}", skill.name, skill.body));
3333 let msg = format!(
3334 "Skill `{}` loaded — instructions will be active for the next turn.",
3335 skill.name
3336 );
3337 for chunk in chunk_text(&msg, 8) {
3338 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3339 }
3340 }
3341 } else {
3342 let available: Vec<&str> =
3343 discovery.skills.iter().map(|s| s.name.as_str()).collect();
3344 let msg = if available.is_empty() {
3345 format!(
3346 "No skill named `{}` found. No skills are currently discovered.",
3347 skill_name
3348 )
3349 } else {
3350 format!(
3351 "No skill named `{}` found. Available: {}",
3352 skill_name,
3353 available.join(", ")
3354 )
3355 };
3356 for chunk in chunk_text(&msg, 8) {
3357 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3358 }
3359 }
3360 let _ = tx.send(InferenceEvent::Done).await;
3361 return Ok(());
3362 }
3363
3364 if let Some(new_name) = user_input
3366 .trim()
3367 .strip_prefix("/skill new ")
3368 .map(str::trim)
3369 .filter(|s| !s.is_empty())
3370 {
3371 let slug = new_name
3372 .to_lowercase()
3373 .replace(' ', "-")
3374 .chars()
3375 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
3376 .collect::<String>();
3377 let skill_dir = crate::tools::file_ops::workspace_root()
3378 .join(".agents")
3379 .join("skills")
3380 .join(&slug);
3381 let skill_path = skill_dir.join("SKILL.md");
3382 let msg = if skill_path.exists() {
3383 format!(
3384 "Skill `{}` already exists at `{}`.",
3385 slug,
3386 skill_path.display()
3387 )
3388 } else {
3389 match std::fs::create_dir_all(&skill_dir) {
3390 Err(e) => format!("Failed to create skill directory: {}", e),
3391 Ok(()) => {
3392 let template = format!(
3393 "---\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"
3394 );
3395 match std::fs::write(&skill_path, template) {
3396 Ok(()) => format!(
3397 "Created `{}` — edit the description, triggers, and instructions, then use `/skill {}` to load it.",
3398 skill_path.display(),
3399 slug
3400 ),
3401 Err(e) => format!("Failed to write SKILL.md: {}", e),
3402 }
3403 }
3404 }
3405 };
3406 for chunk in chunk_text(&msg, 8) {
3407 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3408 }
3409 let _ = tx.send(InferenceEvent::Done).await;
3410 return Ok(());
3411 }
3412
3413 if user_input.trim() == "/vein-reset" {
3414 tokio::task::block_in_place(|| self.vein.reset());
3415 let _ = tx
3416 .send(InferenceEvent::VeinStatus {
3417 file_count: 0,
3418 embedded_count: 0,
3419 docs_only: self.vein_docs_only_mode(),
3420 })
3421 .await;
3422 for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
3423 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3424 }
3425 let _ = tx.send(InferenceEvent::Done).await;
3426 return Ok(());
3427 }
3428
3429 if user_input.trim() == "/compact" {
3430 let context_length = self.engine.current_context_length();
3431 let vram_ratio = self.gpu_state.ratio();
3432 let config = compaction::CompactionConfig::adaptive(context_length, vram_ratio);
3433 let before_len = self.history.len();
3434 let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
3435 let result = compaction::compact_history(
3436 &self.history,
3437 self.running_summary.as_deref(),
3438 config,
3439 None,
3440 );
3441 let removed = before_len.saturating_sub(result.messages.len());
3442 self.history = result.messages;
3443 self.running_summary = result.summary;
3444 let last_checkpoint = self.session_memory.last_checkpoint.take();
3445 let last_blocker = self.session_memory.last_blocker.take();
3446 let last_recovery = self.session_memory.last_recovery.take();
3447 let last_verification = self.session_memory.last_verification.take();
3448 let last_compaction = self.session_memory.last_compaction.take();
3449 self.session_memory = compaction::extract_memory(&self.history);
3450 self.session_memory.last_checkpoint = last_checkpoint;
3451 self.session_memory.last_blocker = last_blocker;
3452 self.session_memory.last_recovery = last_recovery;
3453 self.session_memory.last_verification = last_verification;
3454 self.session_memory.last_compaction = last_compaction;
3455 self.session_memory.record_compaction(
3456 removed,
3457 format!(
3458 "Manual /compact: task '{}', {} file(s) in working set.",
3459 self.session_memory.current_task,
3460 self.session_memory.working_set.len()
3461 ),
3462 );
3463 self.emit_compaction_pressure(&tx).await;
3464 let after_tokens = compaction::estimate_compactable_tokens(&self.history);
3465 let msg = format!(
3466 "History compacted. {} message(s) summarized, ~{} tokens freed. \
3467 Remaining: ~{} tokens. Active task: \"{}\".",
3468 removed,
3469 estimated_tokens.saturating_sub(after_tokens),
3470 after_tokens,
3471 self.session_memory.current_task,
3472 );
3473 for chunk in chunk_text(&msg, 8) {
3474 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3475 }
3476 let _ = tx.send(InferenceEvent::Done).await;
3477 return Ok(());
3478 }
3479
3480 if user_input.trim() == "/budget" {
3481 let msg = match &self.last_turn_budget {
3482 Some(b) => b.render(),
3483 None => "No turn budget recorded yet — run a prompt first.".to_string(),
3484 };
3485 for chunk in chunk_text(&msg, 8) {
3486 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3487 }
3488 let _ = tx.send(InferenceEvent::Done).await;
3489 return Ok(());
3490 }
3491
3492 {
3494 let trimmed = user_input.trim();
3495
3496 if trimmed == "/task" || trimmed == "/task list" {
3498 let tasks = crate::agent::tasks::load();
3499 let report = crate::agent::tasks::render_list(&tasks);
3500 for chunk in chunk_text(&report, 8) {
3501 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3502 }
3503 let _ = tx.send(InferenceEvent::Done).await;
3504 return Ok(());
3505 }
3506
3507 if let Some(text) = trimmed
3509 .strip_prefix("/task add ")
3510 .map(str::trim)
3511 .filter(|s| !s.is_empty())
3512 {
3513 let tasks = crate::agent::tasks::add(text);
3514 let added = tasks
3515 .iter()
3516 .find(|t| t.text == text.trim())
3517 .map(|t| t.id)
3518 .unwrap_or(0);
3519 let msg = format!("Task {} added: {}", added, text.trim());
3520 for chunk in chunk_text(&msg, 8) {
3521 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3522 }
3523 let _ = tx.send(InferenceEvent::Done).await;
3524 return Ok(());
3525 }
3526
3527 if let Some(n_str) = trimmed.strip_prefix("/task done ").map(str::trim) {
3529 let msg = match n_str.parse::<usize>() {
3530 Ok(n) => match crate::agent::tasks::mark_done(n) {
3531 Ok(tasks) => {
3532 let task = tasks.iter().find(|t| t.id == n);
3533 format!(
3534 "Task {} marked done: {}",
3535 n,
3536 task.map(|t| t.text.as_str()).unwrap_or("")
3537 )
3538 }
3539 Err(e) => e,
3540 },
3541 Err(_) => "Usage: /task done <number> (e.g. `/task done 2`)".to_string(),
3542 };
3543 for chunk in chunk_text(&msg, 8) {
3544 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3545 }
3546 let _ = tx.send(InferenceEvent::Done).await;
3547 return Ok(());
3548 }
3549
3550 if let Some(n_str) = trimmed.strip_prefix("/task remove ").map(str::trim) {
3552 let msg = match n_str.parse::<usize>() {
3553 Ok(n) => match crate::agent::tasks::remove(n) {
3554 Ok(_) => format!("Task {} removed.", n),
3555 Err(e) => e,
3556 },
3557 Err(_) => "Usage: /task remove <number> (e.g. `/task remove 3`)".to_string(),
3558 };
3559 for chunk in chunk_text(&msg, 8) {
3560 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3561 }
3562 let _ = tx.send(InferenceEvent::Done).await;
3563 return Ok(());
3564 }
3565
3566 if trimmed == "/task clear" {
3568 crate::agent::tasks::clear();
3569 for chunk in chunk_text("All tasks cleared.", 8) {
3570 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3571 }
3572 let _ = tx.send(InferenceEvent::Done).await;
3573 return Ok(());
3574 }
3575 }
3576
3577 {
3579 let trimmed = user_input.trim();
3580
3581 if trimmed == "/pr" || trimmed.starts_with("/pr ") {
3583 let rest = trimmed.strip_prefix("/pr").unwrap_or("").trim();
3584 let draft = rest.contains("--draft");
3585 let title_part = rest.trim_start_matches("--draft").trim();
3586 let title = if title_part.is_empty() {
3587 None
3588 } else {
3589 Some(title_part)
3590 };
3591 let msg = match crate::tools::github::create_pr_from_context(title, draft) {
3592 Ok(out) => out,
3593 Err(e) => format!("PR creation failed: {}", e),
3594 };
3595 for chunk in chunk_text(&msg, 8) {
3596 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3597 }
3598 let _ = tx.send(InferenceEvent::Done).await;
3599 return Ok(());
3600 }
3601
3602 if trimmed == "/ci" {
3604 let msg = match crate::tools::github::ci_status_current() {
3605 Ok(out) if out.trim().is_empty() => {
3606 "No CI runs found for this branch. Push to GitHub and trigger a workflow first.".to_string()
3607 }
3608 Ok(out) => format!("## CI Status\n\n```\n{}\n```", out.trim()),
3609 Err(e) => format!("CI status failed: {}", e),
3610 };
3611 for chunk in chunk_text(&msg, 8) {
3612 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3613 }
3614 let _ = tx.send(InferenceEvent::Done).await;
3615 return Ok(());
3616 }
3617
3618 if trimmed == "/issue" || trimmed.starts_with("/issue ") {
3620 let rest = trimmed.strip_prefix("/issue").unwrap_or("").trim();
3621 let args = if rest.is_empty() {
3622 serde_json::json!({ "action": "issue_list", "limit": 10 })
3623 } else if let Ok(n) = rest.parse::<u64>() {
3624 serde_json::json!({ "action": "issue_view", "number": n })
3625 } else {
3626 serde_json::json!({ "action": "issue_list", "limit": 10, "state": rest })
3627 };
3628 let msg = match crate::tools::github::execute(&args).await {
3629 Ok(out) if out.trim().is_empty() => "No issues found.".to_string(),
3630 Ok(out) => format!("## Issues\n\n```\n{}\n```", out.trim()),
3631 Err(e) => format!("Issue lookup failed: {}", e),
3632 };
3633 for chunk in chunk_text(&msg, 8) {
3634 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3635 }
3636 let _ = tx.send(InferenceEvent::Done).await;
3637 return Ok(());
3638 }
3639 }
3640
3641 if user_input.trim() == "/fix" || user_input.trim() == "/fix --test" {
3643 let action = if user_input.trim() == "/fix --test" {
3644 "test"
3645 } else {
3646 "build"
3647 };
3648 let _ = tx
3649 .send(InferenceEvent::Thought(format!(
3650 "Running verify_build({action}) to capture current error state..."
3651 )))
3652 .await;
3653 let result =
3654 crate::tools::verify_build::execute(&serde_json::json!({ "action": action })).await;
3655 let (ok, output) = match result {
3656 Ok(out) => (true, out),
3657 Err(e) => (false, e),
3658 };
3659 if ok {
3660 for chunk in chunk_text(
3661 &format!(
3662 "Build is clean — nothing to fix.\n\n```\n{}\n```",
3663 output.trim()
3664 ),
3665 8,
3666 ) {
3667 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3668 }
3669 } else {
3670 let capped: String = output.chars().take(3000).collect();
3672 for chunk in chunk_text(
3673 &format!(
3674 "Build failed. Fix context loaded — send any message to start fixing.\n\n```\n{}\n```",
3675 capped.trim()
3676 ),
3677 8,
3678 ) {
3679 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3680 }
3681 self.pending_fix_context = Some(capped);
3682 }
3683 let _ = tx.send(InferenceEvent::Done).await;
3684 return Ok(());
3685 }
3686
3687 let config = crate::agent::config::load_config();
3689 self.recovery_context.clear();
3690 let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
3691 if !manual_runtime_refresh {
3692 if let Some((model_id, context_length, changed)) = self
3693 .refresh_runtime_profile_and_report(&tx, "turn_start")
3694 .await
3695 {
3696 if changed {
3697 let _ = tx
3698 .send(InferenceEvent::Thought(format!(
3699 "Runtime refresh: using model `{}` with CTX {} for this turn.",
3700 model_id, context_length
3701 )))
3702 .await;
3703 }
3704 }
3705 }
3706 self.emit_embed_profile(&tx).await;
3707 self.emit_compaction_pressure(&tx).await;
3708 let current_model = self.engine.current_model();
3709 self.engine.set_gemma_native_formatting(
3710 crate::agent::config::effective_gemma_native_formatting(&config, ¤t_model),
3711 );
3712 let _turn_id = self.begin_grounded_turn().await;
3713 let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
3714 let mcp_tools = match self.refresh_mcp_tools(&tx).await {
3715 Ok(tools) => tools,
3716 Err(e) => {
3717 let _ = tx
3718 .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
3719 .await;
3720 Vec::new()
3721 }
3722 };
3723
3724 let effective_fast = config
3726 .fast_model
3727 .clone()
3728 .or_else(|| self.fast_model.clone());
3729 let effective_think = config
3730 .think_model
3731 .clone()
3732 .or_else(|| self.think_model.clone());
3733
3734 let trimmed_input = user_input.trim();
3735
3736 if trimmed_input == "/model" || trimmed_input.starts_with("/model ") {
3737 let arg_text = trimmed_input.strip_prefix("/model").unwrap_or("").trim();
3738 let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3739 Ok(self.runtime_model_status_report(&config).await)
3740 } else if let Some(list_args) = arg_text.strip_prefix("list").map(str::trim) {
3741 let loaded_only = if list_args.is_empty()
3742 || list_args.eq_ignore_ascii_case("available")
3743 {
3744 false
3745 } else if list_args.eq_ignore_ascii_case("loaded") {
3746 true
3747 } else {
3748 for chunk in chunk_text(&format!("Usage: {}", Self::model_command_usage()), 8) {
3749 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3750 }
3751 let _ = tx.send(InferenceEvent::Done).await;
3752 return Ok(());
3753 };
3754 let provider = self.engine.provider_name().await;
3755 self.format_provider_model_inventory(
3756 &provider,
3757 crate::agent::provider::ProviderModelKind::Coding,
3758 loaded_only,
3759 )
3760 .await
3761 } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3762 if load_args.is_empty() {
3763 Err(format!("Usage: {}", Self::model_command_usage()))
3764 } else {
3765 let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3766 self.load_runtime_model_now(&tx, &model_id, "coding", context_length)
3767 .await
3768 }
3769 } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3770 let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3771 self.unload_runtime_model_now(&tx, target.as_deref(), "coding", unload_all)
3772 .await
3773 } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3774 if model_id.is_empty() {
3775 Err(format!("Usage: {}", Self::model_command_usage()))
3776 } else {
3777 crate::agent::config::set_preferred_coding_model(Some(model_id)).map(|_| {
3778 format!(
3779 "Saved preferred coding model `{}` in `.hematite/settings.json`. Use `/model load {}` now or restart Hematite to let startup policy load it automatically.",
3780 model_id, model_id
3781 )
3782 })
3783 }
3784 } else if matches!(arg_text, "clear" | "clear-preference") {
3785 crate::agent::config::set_preferred_coding_model(None)
3786 .map(|_| "Cleared the saved preferred coding model.".to_string())
3787 } else {
3788 Err(format!("Usage: {}", Self::model_command_usage()))
3789 };
3790
3791 for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3792 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3793 }
3794 let _ = tx.send(InferenceEvent::Done).await;
3795 return Ok(());
3796 }
3797
3798 if trimmed_input == "/embed" || trimmed_input.starts_with("/embed ") {
3799 let arg_text = trimmed_input.strip_prefix("/embed").unwrap_or("").trim();
3800 let response = if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
3801 Ok(self.runtime_model_status_report(&config).await)
3802 } else if let Some(load_args) = arg_text.strip_prefix("load ").map(str::trim) {
3803 if load_args.is_empty() {
3804 Err(format!("Usage: {}", Self::embed_command_usage()))
3805 } else {
3806 let (model_id, context_length) = Self::parse_model_load_args(load_args)?;
3807 if context_length.is_some() {
3808 Err("`/embed load` does not accept `--ctx`. Embedding models do not use a chat context window here.".to_string())
3809 } else {
3810 self.load_runtime_model_now(&tx, &model_id, "embed", None)
3811 .await
3812 }
3813 }
3814 } else if let Some(unload_args) = arg_text.strip_prefix("unload").map(str::trim) {
3815 let (target, unload_all) = Self::parse_unload_target(unload_args)?;
3816 if unload_all {
3817 Err("`/embed unload` supports the current embed model or an explicit embed model ID, not `all`.".to_string())
3818 } else {
3819 self.unload_runtime_model_now(&tx, target.as_deref(), "embed", false)
3820 .await
3821 }
3822 } else if let Some(model_id) = arg_text.strip_prefix("prefer ").map(str::trim) {
3823 if model_id.is_empty() {
3824 Err(format!("Usage: {}", Self::embed_command_usage()))
3825 } else {
3826 crate::agent::config::set_preferred_embed_model(Some(model_id)).map(|_| {
3827 format!(
3828 "Saved preferred embed model `{}` in `.hematite/settings.json`. Use `/embed load {}` now or restart Hematite to let startup policy load it automatically.",
3829 model_id, model_id
3830 )
3831 })
3832 }
3833 } else if matches!(arg_text, "clear" | "clear-preference") {
3834 crate::agent::config::set_preferred_embed_model(None)
3835 .map(|_| "Cleared the saved preferred embed model.".to_string())
3836 } else {
3837 Err(format!("Usage: {}", Self::embed_command_usage()))
3838 };
3839
3840 for chunk in chunk_text(&response.unwrap_or_else(|e| e), 8) {
3841 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3842 }
3843 let _ = tx.send(InferenceEvent::Done).await;
3844 return Ok(());
3845 }
3846
3847 if user_input.trim() == "/lsp" {
3849 let mut lsp = self.lsp_manager.lock().await;
3850 match lsp.start_servers().await {
3851 Ok(_) => {
3852 let _ = tx
3853 .send(InferenceEvent::MutedToken(
3854 "LSP: Servers Initialized OK.".to_string(),
3855 ))
3856 .await;
3857 }
3858 Err(e) => {
3859 let _ = tx
3860 .send(InferenceEvent::Error(format!(
3861 "LSP: Failed to start servers - {}",
3862 e
3863 )))
3864 .await;
3865 }
3866 }
3867 let _ = tx.send(InferenceEvent::Done).await;
3868 return Ok(());
3869 }
3870
3871 if user_input.trim() == "/runtime-refresh" {
3872 match self
3873 .refresh_runtime_profile_and_report(&tx, "manual_command")
3874 .await
3875 {
3876 Some((model_id, context_length, changed)) => {
3877 let msg = if changed {
3878 format!(
3879 "Runtime profile refreshed. Model: {} | CTX: {}",
3880 model_id, context_length
3881 )
3882 } else {
3883 format!(
3884 "Runtime profile unchanged. Model: {} | CTX: {}",
3885 model_id, context_length
3886 )
3887 };
3888 for chunk in chunk_text(&msg, 8) {
3889 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3890 }
3891 }
3892 None => {
3893 let provider_name = self.engine.provider_name().await;
3894 let endpoint = crate::runtime::session_endpoint_url(&self.engine.base_url);
3895 let alternative =
3896 crate::runtime::detect_alternative_provider(&provider_name).await;
3897 let mut message = format!(
3898 "Runtime refresh failed: {} could not be read at {}.",
3899 provider_name, endpoint
3900 );
3901 if let Some((alt_name, alt_url)) = alternative {
3902 let _ = write!(
3903 message,
3904 " Reachable alternative detected: {} ({})",
3905 alt_name, alt_url
3906 );
3907 }
3908 let _ = tx.send(InferenceEvent::Error(message)).await;
3909 }
3910 }
3911 let _ = tx.send(InferenceEvent::Done).await;
3912 return Ok(());
3913 }
3914
3915 if user_input.trim() == "/ask" {
3916 self.set_workflow_mode(WorkflowMode::Ask);
3917 for chunk in chunk_text(
3918 "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
3919 8,
3920 ) {
3921 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3922 }
3923 let _ = tx.send(InferenceEvent::Done).await;
3924 return Ok(());
3925 }
3926
3927 if user_input.trim() == "/code" {
3928 self.set_workflow_mode(WorkflowMode::Code);
3929 let mut message =
3930 "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
3931 if let Some(plan) = self.current_plan_summary() {
3932 let _ = write!(message, " Current plan: {plan}.");
3933 }
3934 for chunk in chunk_text(&message, 8) {
3935 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3936 }
3937 let _ = tx.send(InferenceEvent::Done).await;
3938 return Ok(());
3939 }
3940
3941 if user_input.trim() == "/architect" {
3942 self.set_workflow_mode(WorkflowMode::Architect);
3943 let mut message =
3944 "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();
3945 if let Some(plan) = self.current_plan_summary() {
3946 let _ = write!(message, " Existing plan: {plan}.");
3947 }
3948 for chunk in chunk_text(&message, 8) {
3949 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3950 }
3951 let _ = tx.send(InferenceEvent::Done).await;
3952 return Ok(());
3953 }
3954
3955 if user_input.trim() == "/read-only" {
3956 self.set_workflow_mode(WorkflowMode::ReadOnly);
3957 for chunk in chunk_text(
3958 "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
3959 8,
3960 ) {
3961 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3962 }
3963 let _ = tx.send(InferenceEvent::Done).await;
3964 return Ok(());
3965 }
3966
3967 if user_input.trim() == "/auto" {
3968 self.set_workflow_mode(WorkflowMode::Auto);
3969 for chunk in chunk_text(
3970 "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
3971 8,
3972 ) {
3973 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3974 }
3975 let _ = tx.send(InferenceEvent::Done).await;
3976 return Ok(());
3977 }
3978
3979 if user_input.trim() == "/chat" {
3980 self.set_workflow_mode(WorkflowMode::Chat);
3981 let _ = tx.send(InferenceEvent::Done).await;
3982 return Ok(());
3983 }
3984
3985 if user_input.trim() == "/teach" {
3986 self.set_workflow_mode(WorkflowMode::Teach);
3987 for chunk in chunk_text(
3988 "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.",
3989 8,
3990 ) {
3991 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3992 }
3993 let _ = tx.send(InferenceEvent::Done).await;
3994 return Ok(());
3995 }
3996
3997 if user_input.trim() == "/reroll" {
3998 let soul = crate::ui::hatch::generate_soul_random();
3999 self.snark = soul.snark;
4000 self.chaos = soul.chaos;
4001 self.soul_personality = soul.personality.clone();
4002 let species = soul.species.clone();
4007 if let Some(eng) = Arc::get_mut(&mut self.engine) {
4008 eng.species = species.clone();
4009 }
4010 let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
4011 let _ = tx
4012 .send(InferenceEvent::SoulReroll {
4013 species: soul.species.clone(),
4014 rarity: soul.rarity.label().to_string(),
4015 shiny: soul.shiny,
4016 personality: soul.personality.clone(),
4017 })
4018 .await;
4019 for chunk in chunk_text(
4020 &format!(
4021 "A new companion awakens!\n[{}{}] {} — \"{}\"",
4022 soul.rarity.label(),
4023 shiny_tag,
4024 soul.species,
4025 soul.personality
4026 ),
4027 8,
4028 ) {
4029 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4030 }
4031 let _ = tx.send(InferenceEvent::Done).await;
4032 return Ok(());
4033 }
4034
4035 if user_input.trim() == "/agent" {
4036 self.set_workflow_mode(WorkflowMode::Auto);
4037 let _ = tx.send(InferenceEvent::Done).await;
4038 return Ok(());
4039 }
4040
4041 let implement_plan_alias = user_input.trim() == "/implement-plan";
4042 if implement_plan_alias
4043 && !self
4044 .session_memory
4045 .current_plan
4046 .as_ref()
4047 .map(|plan| plan.has_signal())
4048 .unwrap_or(false)
4049 {
4050 for chunk in chunk_text(
4051 "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
4052 8,
4053 ) {
4054 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4055 }
4056 let _ = tx.send(InferenceEvent::Done).await;
4057 return Ok(());
4058 }
4059
4060 let mut effective_user_input = if implement_plan_alias {
4061 self.set_workflow_mode(WorkflowMode::Code);
4062 implement_current_plan_prompt().to_string()
4063 } else {
4064 user_input.trim().to_string()
4065 };
4066 if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
4067 self.set_workflow_mode(mode);
4068 effective_user_input = rest.to_string();
4069 }
4070 let transcript_user_input = if implement_plan_alias {
4071 transcript_user_turn_text(user_turn, "/implement-plan")
4072 } else {
4073 transcript_user_turn_text(user_turn, &effective_user_input)
4074 };
4075 effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
4076 self.register_at_file_mentions(user_input).await;
4079 let implement_current_plan = self.workflow_mode == WorkflowMode::Code
4080 && is_current_plan_execution_request(&effective_user_input)
4081 && self
4082 .session_memory
4083 .current_plan
4084 .as_ref()
4085 .map(|plan| plan.has_signal())
4086 .unwrap_or(false);
4087 let explicit_search_request = is_explicit_web_search_request(&effective_user_input);
4088 let mut grounded_research_results: Option<String> = None;
4089 self.plan_execution_active
4090 .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
4091 let _plan_execution_guard = PlanExecutionGuard {
4092 flag: self.plan_execution_active.clone(),
4093 };
4094 let task_progress_before = if implement_current_plan {
4095 read_task_checklist_progress()
4096 } else {
4097 None
4098 };
4099 let current_plan_pass = if implement_current_plan {
4100 self.plan_execution_pass_depth
4101 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
4102 + 1
4103 } else {
4104 0
4105 };
4106 let _plan_execution_pass_guard = implement_current_plan.then(|| PlanExecutionPassGuard {
4107 depth: self.plan_execution_pass_depth.clone(),
4108 });
4109 let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
4110
4111 if should_use_turn_scoped_investigation_mode(self.workflow_mode, intent.primary_class) {
4113 let _ = tx
4114 .send(InferenceEvent::Thought(
4115 "Seamless search detected: using investigation mode for this turn...".into(),
4116 ))
4117 .await;
4118 }
4119
4120 if let Some(answer_kind) = intent.direct_answer {
4122 match answer_kind {
4123 DirectAnswerKind::About => {
4124 let response = build_about_answer();
4125 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4126 .await;
4127 return Ok(());
4128 }
4129 DirectAnswerKind::LanguageCapability => {
4130 let response = build_language_capability_answer();
4131 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4132 .await;
4133 return Ok(());
4134 }
4135 DirectAnswerKind::UnsafeWorkflowPressure => {
4136 let response = build_unsafe_workflow_pressure_answer();
4137 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4138 .await;
4139 return Ok(());
4140 }
4141 DirectAnswerKind::SessionMemory => {
4142 let response = build_session_memory_answer();
4143 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4144 .await;
4145 return Ok(());
4146 }
4147 DirectAnswerKind::RecoveryRecipes => {
4148 let response = build_recovery_recipes_answer();
4149 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4150 .await;
4151 return Ok(());
4152 }
4153 DirectAnswerKind::McpLifecycle => {
4154 let response = build_mcp_lifecycle_answer();
4155 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4156 .await;
4157 return Ok(());
4158 }
4159 DirectAnswerKind::AuthorizationPolicy => {
4160 let response = build_authorization_policy_answer();
4161 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4162 .await;
4163 return Ok(());
4164 }
4165 DirectAnswerKind::ToolClasses => {
4166 let response = build_tool_classes_answer();
4167 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4168 .await;
4169 return Ok(());
4170 }
4171 DirectAnswerKind::ToolRegistryOwnership => {
4172 let response = build_tool_registry_ownership_answer();
4173 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4174 .await;
4175 return Ok(());
4176 }
4177 DirectAnswerKind::SessionResetSemantics => {
4178 let response = build_session_reset_semantics_answer();
4179 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4180 .await;
4181 return Ok(());
4182 }
4183 DirectAnswerKind::ProductSurface => {
4184 let response = build_product_surface_answer();
4185 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4186 .await;
4187 return Ok(());
4188 }
4189 DirectAnswerKind::ReasoningSplit => {
4190 let response = build_reasoning_split_answer();
4191 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4192 .await;
4193 return Ok(());
4194 }
4195 DirectAnswerKind::Identity => {
4196 let response = build_identity_answer();
4197 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4198 .await;
4199 return Ok(());
4200 }
4201 DirectAnswerKind::WorkflowModes => {
4202 let response = build_workflow_modes_answer();
4203 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4204 .await;
4205 return Ok(());
4206 }
4207 DirectAnswerKind::GemmaNative => {
4208 let response = build_gemma_native_answer();
4209 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4210 .await;
4211 return Ok(());
4212 }
4213 DirectAnswerKind::GemmaNativeSettings => {
4214 let response = build_gemma_native_settings_answer();
4215 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4216 .await;
4217 return Ok(());
4218 }
4219 DirectAnswerKind::VerifyProfiles => {
4220 let response = build_verify_profiles_answer();
4221 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4222 .await;
4223 return Ok(());
4224 }
4225 DirectAnswerKind::Toolchain => {
4226 let lower = effective_user_input.to_lowercase();
4227 let topic = if (lower.contains("voice output") || lower.contains("voice"))
4228 && (lower.contains("lag")
4229 || lower.contains("behind visible text")
4230 || lower.contains("latency"))
4231 {
4232 "voice_latency_plan"
4233 } else {
4234 "all"
4235 };
4236 let response =
4237 crate::tools::toolchain::describe_toolchain(&serde_json::json!({
4238 "topic": topic,
4239 "question": effective_user_input,
4240 }))
4241 .await
4242 .unwrap_or_else(|e| format!("Error: {}", e));
4243 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4244 .await;
4245 return Ok(());
4246 }
4247 DirectAnswerKind::HostInspection => {
4248 let topics = all_host_inspection_topics(&effective_user_input);
4249 let response = if topics.len() >= 2 {
4250 let mut combined = Vec::with_capacity(topics.len());
4251 for topic in topics {
4252 let args =
4253 host_inspection_args_from_prompt(topic, &effective_user_input);
4254 let output = crate::tools::host_inspect::inspect_host(&args)
4255 .await
4256 .unwrap_or_else(|e| format!("Error (topic {topic}): {e}"));
4257 combined.push(format!("# Topic: {topic}\n{output}"));
4258 }
4259 combined.join("\n\n---\n\n")
4260 } else {
4261 let topic = preferred_host_inspection_topic(&effective_user_input)
4262 .unwrap_or("summary");
4263 let args = host_inspection_args_from_prompt(topic, &effective_user_input);
4264 crate::tools::host_inspect::inspect_host(&args)
4265 .await
4266 .unwrap_or_else(|e| format!("Error: {e}"))
4267 };
4268
4269 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4270 .await;
4271 return Ok(());
4272 }
4273 DirectAnswerKind::ArchitectSessionResetPlan => {
4274 let plan = build_architect_session_reset_plan();
4275 let response = plan.to_markdown();
4276 let _ = crate::tools::plan::save_plan_handoff(&plan);
4277 self.session_memory.current_plan = Some(plan);
4278 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4279 .await;
4280 return Ok(());
4281 }
4282 DirectAnswerKind::Help => {
4283 let response = build_help_answer();
4284 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4285 .await;
4286 return Ok(());
4287 }
4288 }
4289 }
4290
4291 if matches!(
4292 self.workflow_mode,
4293 WorkflowMode::Ask | WorkflowMode::ReadOnly
4294 ) && looks_like_mutation_request(&effective_user_input)
4295 {
4296 let response = build_mode_redirect_answer(self.workflow_mode);
4297 self.history.push(ChatMessage::user(&effective_user_input));
4298 self.history.push(ChatMessage::assistant_text(&response));
4299 self.transcript.log_user(&transcript_user_input);
4300 self.transcript.log_agent(&response);
4301 for chunk in chunk_text(&response, 8) {
4302 if !chunk.is_empty() {
4303 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4304 }
4305 }
4306 let _ = tx.send(InferenceEvent::Done).await;
4307 self.trim_history(80);
4308 self.refresh_session_memory();
4309 self.save_session();
4310 return Ok(());
4311 }
4312
4313 if user_input.trim() == "/think" {
4314 self.think_mode = Some(true);
4315 for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
4316 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4317 }
4318 let _ = tx.send(InferenceEvent::Done).await;
4319 return Ok(());
4320 }
4321 if user_input.trim() == "/no_think" {
4322 self.think_mode = Some(false);
4323 for chunk in chunk_text(
4324 "Think mode: OFF — fast mode enabled (no chain-of-thought).",
4325 8,
4326 ) {
4327 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4328 }
4329 let _ = tx.send(InferenceEvent::Done).await;
4330 return Ok(());
4331 }
4332
4333 if user_input.trim_start().starts_with("/pin ") {
4335 let path = user_input.trim_start()[5..].trim();
4336 match std::fs::read_to_string(path) {
4337 Ok(content) => {
4338 self.pinned_files
4339 .write()
4340 .await
4341 .insert(path.to_string(), content);
4342 let msg = format!(
4343 "Pinned: {} — this file is now locked in model context.",
4344 path
4345 );
4346 for chunk in chunk_text(&msg, 8) {
4347 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4348 }
4349 }
4350 Err(e) => {
4351 let _ = tx
4352 .send(InferenceEvent::Error(format!(
4353 "Failed to pin {}: {}",
4354 path, e
4355 )))
4356 .await;
4357 }
4358 }
4359 let _ = tx.send(InferenceEvent::Done).await;
4360 return Ok(());
4361 }
4362
4363 if user_input.trim_start().starts_with("/unpin ") {
4365 let path = user_input.trim_start()[7..].trim();
4366 if self.pinned_files.write().await.remove(path).is_some() {
4367 let msg = format!("Unpinned: {} — file removed from active context.", path);
4368 for chunk in chunk_text(&msg, 8) {
4369 let _ = tx.send(InferenceEvent::Token(chunk)).await;
4370 }
4371 } else {
4372 let _ = tx
4373 .send(InferenceEvent::Error(format!(
4374 "File {} was not pinned.",
4375 path
4376 )))
4377 .await;
4378 }
4379 let _ = tx.send(InferenceEvent::Done).await;
4380 return Ok(());
4381 }
4382
4383 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
4387 if let Some(root) = extract_sovereign_scaffold_root(&effective_user_input) {
4388 if std::fs::create_dir_all(&root).is_ok() {
4389 let targets = default_sovereign_scaffold_targets(&effective_user_input);
4390 let _ = seed_sovereign_scaffold_files(&root, &targets);
4391 let plan = build_sovereign_scaffold_handoff(&effective_user_input, &targets);
4392 let _ = crate::tools::plan::save_plan_handoff_for_root(&root, &plan);
4393 let _ = crate::tools::plan::write_teleport_resume_marker_for_root(&root);
4394 let _ = write_sovereign_handoff_markdown(&root, &effective_user_input, &plan);
4395 self.pending_teleport_handoff = None;
4396 self.latest_target_dir = Some(root.to_string_lossy().to_string());
4397 let response = format!(
4398 "Created the sovereign project root at `{}` and wrote a local handoff. Teleporting now so the next session can continue implementation inside that project.",
4399 root.display()
4400 );
4401 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
4402 .await;
4403 return Ok(());
4404 }
4405 }
4406 }
4407
4408 let tiny_context_mode = self.engine.current_context_length() <= 8_192;
4409 let mut base_prompt = self.engine.build_system_prompt(
4410 self.snark,
4411 self.chaos,
4412 self.brief,
4413 self.professional,
4414 &self.tools,
4415 self.reasoning_history.as_deref(),
4416 None,
4417 &mcp_tools,
4418 );
4419 if !tiny_context_mode {
4420 if let Some(hint) = &config.context_hint {
4421 if !hint.trim().is_empty() {
4422 let _ = write!(
4423 base_prompt,
4424 "\n\n# Project Context (from .hematite/settings.json)\n{}",
4425 hint
4426 );
4427 }
4428 }
4429 if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
4430 &crate::tools::file_ops::workspace_root(),
4431 ) {
4432 let _ = write!(base_prompt, "\n\n{}", profile_block);
4433 }
4434 if let Some(strategy_block) =
4435 crate::agent::workspace_profile::profile_strategy_prompt_block(
4436 &crate::tools::file_ops::workspace_root(),
4437 )
4438 {
4439 let _ = write!(base_prompt, "\n\n{}", strategy_block);
4440 }
4441 if let Some(ref l1) = self.l1_context {
4443 let _ = write!(base_prompt, "\n\n{}", l1);
4444 }
4445 if let Some(ref repo_map_block) = self.repo_map {
4446 let _ = write!(base_prompt, "\n\n{}", repo_map_block);
4447 }
4448 }
4449 let grounded_trace_mode = intent.grounded_trace_mode
4450 || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
4451 let capability_mode =
4452 intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
4453 let toolchain_mode =
4454 intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
4455 let host_inspection_mode = if intent.host_inspection_mode {
4460 let api_url = self.engine.base_url.clone();
4461 let query = effective_user_input.clone();
4462 let embed_class = tokio::time::timeout(
4463 std::time::Duration::from_millis(600),
4464 crate::agent::intent_embed::classify_intent(&query, &api_url),
4465 )
4466 .await
4467 .unwrap_or(crate::agent::intent_embed::IntentClass::Ambiguous);
4468 !matches!(
4469 embed_class,
4470 crate::agent::intent_embed::IntentClass::Advisory
4471 )
4472 } else {
4473 false
4474 };
4475 let maintainer_workflow_mode = intent.maintainer_workflow_mode
4476 || preferred_maintainer_workflow(&effective_user_input).is_some();
4477 let fix_plan_mode =
4478 preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
4479 let architecture_overview_mode = intent.architecture_overview_mode;
4480 let capability_needs_repo = intent.capability_needs_repo;
4481 let research_mode = (capability_needs_repo || !capability_mode)
4482 && intent.direct_answer.is_none()
4483 && intent.primary_class == QueryIntentClass::Research;
4484 let mut system_msg = build_system_with_corrections(
4485 &base_prompt,
4486 &self.correction_hints,
4487 &self.gpu_state,
4488 &self.git_state,
4489 &config,
4490 );
4491 if !tiny_context_mode && research_mode {
4492 system_msg.push_str(
4493 "\n\n# RESEARCH MODE\n\
4494 This turn is an investigation into external technical information.\n\
4495 Prioritize using the `research_web` tool to find the most current and authoritative data.\n\
4496 When providing information, ground your answer in the search results and cite your sources if possible.\n\
4497 If the user's question involves specific versions or recent releases (e.g., Rust compiler), use the web to verify the exact state.\n"
4498 );
4499 }
4500 if tiny_context_mode {
4501 system_msg.push_str(
4502 "\n\n# TINY CONTEXT TURN MODE\n\
4503 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
4504 );
4505 }
4506 if !tiny_context_mode && grounded_trace_mode {
4507 system_msg.push_str(
4508 "\n\n# GROUNDED TRACE MODE\n\
4509 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
4510 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
4511 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
4512 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
4513 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
4514 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
4515 Do not invent names such as synthetic channels or subsystems.\n\
4516 If a detail is not verified from the code or tool output, say `uncertain`.\n\
4517 For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
4518 );
4519 }
4520 if !tiny_context_mode && capability_mode {
4521 }
4523 if !tiny_context_mode && toolchain_mode {
4524 }
4526 if !tiny_context_mode && host_inspection_mode {
4527 }
4529 if !tiny_context_mode && fix_plan_mode {
4530 system_msg.push_str(
4531 "\n\n# FIX PLAN MODE\n\
4532 This turn is a workstation remediation question, not just a diagnosis question.\n\
4533 Call `inspect_host` with `topic=fix_plan` first.\n\
4534 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
4535 Keep the answer grounded, stepwise, and approval-aware.\n"
4536 );
4537 }
4538 if !tiny_context_mode && maintainer_workflow_mode {
4539 system_msg.push_str(
4540 "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
4541 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
4542 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
4543 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\
4544 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"
4545 );
4546 }
4547 if !tiny_context_mode && architecture_overview_mode {
4550 system_msg.push_str(
4551 "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
4552 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
4553 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\
4554 Preserve grounded tool output rather than restyling it into a larger answer.\n"
4555 );
4556 }
4557
4558 let _ = write!(
4560 system_msg,
4561 "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
4562 self.workflow_mode.label()
4563 );
4564 if tiny_context_mode {
4565 system_msg
4566 .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
4567 }
4568 if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
4569 system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
4570 system_msg.push_str(architect_handoff_contract());
4571 system_msg.push('\n');
4572 }
4573 if !tiny_context_mode && is_scaffold_request(&effective_user_input) {
4574 system_msg.push_str(scaffold_protocol());
4575 }
4576 if !tiny_context_mode {
4577 let workspace_root = crate::tools::file_ops::workspace_root();
4578 let skill_discovery =
4579 crate::agent::instructions::discover_agent_skills(&workspace_root, &config.trust);
4580 if let Some(bodies) = crate::agent::instructions::render_active_skill_bodies(
4581 &skill_discovery,
4582 &effective_user_input,
4583 8_000,
4584 ) {
4585 let _ = write!(system_msg, "\n\n{}", bodies);
4586 }
4587 if let Some(forced_body) = self.pending_skill_inject.take() {
4589 let _ = write!(
4590 system_msg,
4591 "\n\n# Active Skill Instructions\n\n{}",
4592 forced_body
4593 );
4594 }
4595 }
4596 if !tiny_context_mode && implement_current_plan {
4597 system_msg.push_str(
4598 "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
4599 The user explicitly asked you to implement the current saved plan.\n\
4600 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
4601 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
4602 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
4603 If the saved plan explicitly calls for `research_web` or `fetch_docs`, do that research first, then return to the target files.\n\
4604 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",
4605 );
4606 if let Some(plan) = self.session_memory.current_plan.as_ref() {
4607 if !plan.target_files.is_empty() {
4608 system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
4609 for path in &plan.target_files {
4610 let _ = writeln!(system_msg, "- {}", path);
4611 }
4612 }
4613 }
4614 }
4615 if !tiny_context_mode {
4616 let pinned = self.pinned_files.read().await;
4617 if !pinned.is_empty() {
4618 system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
4619 system_msg.push_str("The following files are locked in your active memory for prioritized reference.\n\n");
4620 for (path, content) in pinned.iter() {
4621 let _ = write!(system_msg, "## FILE: {}\n```\n{}\n```\n\n", path, content);
4622 }
4623 }
4624 }
4625 if !tiny_context_mode {
4626 self.append_session_handoff(&mut system_msg);
4627 }
4628 let mut final_system_msg = if self.workflow_mode.is_chat() {
4630 self.build_chat_system_prompt()
4631 } else {
4632 system_msg
4633 };
4634
4635 if !tiny_context_mode
4636 && matches!(self.workflow_mode, WorkflowMode::Code | WorkflowMode::Auto)
4637 {
4638 let task_path = std::path::Path::new(".hematite/TASK.md");
4639 if task_path.exists() {
4640 if let Ok(content) = std::fs::read_to_string(task_path) {
4641 let snippet = if content.lines().count() > 50 {
4642 let mut s = String::with_capacity(50 * 80);
4643 for (i, line) in content.lines().take(50).enumerate() {
4644 if i > 0 {
4645 s.push('\n');
4646 }
4647 s.push_str(line);
4648 }
4649 s + "\n... (truncated)"
4650 } else {
4651 content
4652 };
4653 final_system_msg.push_str("\n\n# CURRENT TASK STATUS (.hematite/TASK.md)\n");
4654 final_system_msg.push_str("Update this file via `edit_file` to check off `[x]` items as you complete them.\n");
4655 final_system_msg.push_str("```markdown\n");
4656 final_system_msg.push_str(&snippet);
4657 final_system_msg.push_str("\n```\n");
4658 }
4659 }
4660 }
4661
4662 if !tiny_context_mode {
4664 let tasks = crate::agent::tasks::load();
4665 if let Some(block) = crate::agent::tasks::render_prompt_block(&tasks) {
4666 final_system_msg.push_str("\n\n");
4667 final_system_msg.push_str(&block);
4668 }
4669 }
4670
4671 if !tiny_context_mode && !self.workflow_mode.is_chat() {
4673 if let Some(ref block) = self.shell_history_block {
4674 final_system_msg.push_str("\n\n");
4675 final_system_msg.push_str(block);
4676 }
4677 }
4678
4679 let system_msg = final_system_msg;
4680 if self.history.is_empty() || self.history[0].role != "system" {
4681 self.history.insert(0, ChatMessage::system(&system_msg));
4682 } else {
4683 self.history[0] = ChatMessage::system(&system_msg);
4684 }
4685
4686 self.cancel_token
4688 .store(false, std::sync::atomic::Ordering::SeqCst);
4689
4690 self.reasoning_history = None;
4693
4694 let is_gemma =
4695 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
4696 let user_content = match self.think_mode {
4697 Some(true) => format!("/think\n{}", effective_user_input),
4698 Some(false) => format!("/no_think\n{}", effective_user_input),
4699 None if !is_gemma
4704 && !self.workflow_mode.is_chat()
4705 && !is_quick_tool_request(&effective_user_input) =>
4706 {
4707 format!("/think\n{}", effective_user_input)
4708 }
4709 None => effective_user_input.clone(),
4710 };
4711 if let Some(image) = user_turn.attached_image.as_ref() {
4712 let image_url =
4713 crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
4714 .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
4715 self.history
4716 .push(ChatMessage::user_with_image(&user_content, &image_url));
4717 } else {
4718 self.history.push(ChatMessage::user(&user_content));
4719 }
4720 self.transcript.log_user(&transcript_user_input);
4721
4722 let vein_docs_only = self.vein_docs_only_mode();
4726 let allow_vein_context = !self.workflow_mode.is_chat()
4727 || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
4728 let (vein_context, vein_paths) = if allow_vein_context {
4729 self.refresh_vein_index();
4730 let _ = tx
4731 .send(InferenceEvent::VeinStatus {
4732 file_count: self.vein.file_count(),
4733 embedded_count: self.vein.embedded_chunk_count(),
4734 docs_only: vein_docs_only,
4735 })
4736 .await;
4737 match self.build_vein_context(&effective_user_input) {
4738 Some((ctx, paths)) => (Some(ctx), paths),
4739 None => (None, Vec::new()),
4740 }
4741 } else {
4742 (None, Vec::new())
4743 };
4744 {
4746 let mut tracker = self.diff_tracker.lock().await;
4747 tracker.reset();
4748 }
4749
4750 let heartbeat = crate::agent::policy::ToolchainHeartbeat::capture();
4752 self.last_heartbeat = Some(heartbeat.clone());
4753
4754 if !vein_paths.is_empty() {
4755 let _ = tx
4756 .send(InferenceEvent::VeinContext { paths: vein_paths })
4757 .await;
4758 }
4759
4760 let routed_model = route_model(
4762 &effective_user_input,
4763 effective_fast.as_deref(),
4764 effective_think.as_deref(),
4765 )
4766 .map(|s| s.to_string());
4767
4768 let mut loop_intervention: Option<String> = None;
4769
4770 {
4777 let topics = all_host_inspection_topics(&effective_user_input);
4778 if topics.len() >= 2 {
4779 let _ = tx
4780 .send(InferenceEvent::Thought(format!(
4781 "Harness pre-run: {} host inspection topics detected — running all before model turn.",
4782 topics.len()
4783 )))
4784 .await;
4785
4786 let topic_list = topics.join(", ");
4787 let mut combined = format!(
4788 "## HARNESS PRE-RUN RESULTS\n\
4789 The harness already ran inspect_host for the following topics: {topic_list}.\n\
4790 Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
4791 );
4792
4793 let mut tool_calls = Vec::with_capacity(topics.len());
4794 let mut tool_msgs = Vec::with_capacity(topics.len());
4795
4796 for topic in &topics {
4797 let call_id = format!("prerun_{topic}");
4798 let mut args_val =
4799 host_inspection_args_from_prompt(topic, &effective_user_input);
4800 if let Some(obj) = args_val.as_object_mut() {
4801 obj.insert("max_entries".to_string(), Value::from(20));
4802 }
4803 let _args_str = serde_json::to_string(&args_val).unwrap_or_default();
4804
4805 tool_calls.push(crate::agent::types::ToolCallResponse {
4806 id: call_id.clone(),
4807 call_type: "function".to_string(),
4808 function: crate::agent::types::ToolCallFn {
4809 name: "inspect_host".to_string(),
4810 arguments: args_val.clone(),
4811 },
4812 index: None,
4813 });
4814
4815 let label = format!("### inspect_host(topic=\"{topic}\")\n");
4816 let _ = tx
4817 .send(InferenceEvent::ToolCallStart {
4818 id: call_id.clone(),
4819 name: "inspect_host".to_string(),
4820 args: format!("inspect host {topic}"),
4821 })
4822 .await;
4823
4824 match crate::tools::host_inspect::inspect_host(&args_val).await {
4825 Ok(out) => {
4826 let _ = tx
4827 .send(InferenceEvent::ToolCallResult {
4828 id: call_id.clone(),
4829 name: "inspect_host".to_string(),
4830 result: out.chars().take(300).collect::<String>() + "...",
4831 is_error: false,
4832 })
4833 .await;
4834 combined.push_str(&label);
4835 combined.push_str(&out);
4836 combined.push_str("\n\n");
4837 tool_msgs.push(ChatMessage::tool_result_for_model(
4838 &call_id,
4839 "inspect_host",
4840 &out,
4841 &self.engine.current_model(),
4842 ));
4843 }
4844 Err(e) => {
4845 let err_msg = format!("Error: {e}");
4846 combined.push_str(&label);
4847 combined.push_str(&err_msg);
4848 combined.push_str("\n\n");
4849 tool_msgs.push(ChatMessage::tool_result_for_model(
4850 &call_id,
4851 "inspect_host",
4852 &err_msg,
4853 &self.engine.current_model(),
4854 ));
4855 }
4856 }
4857 }
4858
4859 self.history
4861 .push(ChatMessage::assistant_tool_calls("", tool_calls));
4862 for msg in tool_msgs {
4863 self.history.push(msg);
4864 }
4865
4866 loop_intervention = Some(combined);
4867 }
4868 }
4869
4870 if loop_intervention.is_none() && research_mode {
4876 let search_query = extract_explicit_web_search_query(&effective_user_input)
4878 .unwrap_or_else(|| effective_user_input.trim().to_string());
4879
4880 let _ = tx
4881 .send(InferenceEvent::Thought(
4882 "Research pre-run: executing search before model turn to ground the answer..."
4883 .into(),
4884 ))
4885 .await;
4886
4887 let call_id = "prerun_research".to_string();
4888 let args = serde_json::json!({ "query": search_query });
4889
4890 let _ = tx
4891 .send(InferenceEvent::ToolCallStart {
4892 id: call_id.clone(),
4893 name: "research_web".to_string(),
4894 args: format!("research_web: {}", search_query),
4895 })
4896 .await;
4897
4898 match crate::tools::research::execute_search(&args, config.searx_url.clone()).await {
4899 Ok(results)
4900 if !results.is_empty() && !results.contains("No search results found") =>
4901 {
4902 grounded_research_results = Some(results.clone());
4903 let _ = tx
4904 .send(InferenceEvent::ToolCallResult {
4905 id: call_id.clone(),
4906 name: "research_web".to_string(),
4907 result: results.chars().take(300).collect::<String>() + "...",
4908 is_error: false,
4909 })
4910 .await;
4911
4912 loop_intervention = Some(format!(
4913 "## RESEARCH PRE-RUN RESULTS\n\
4914 The harness already ran `research_web` for your query.\n\
4915 Use the search results above to answer the user's question with grounded, factual information.\n\
4916 Do NOT re-run `research_web` unless you need additional detail.\n\
4917 Do NOT hallucinate or guess — base your answer entirely on the search results.\n\n\
4918 {}",
4919 results
4920 ));
4921 }
4922 Ok(_) | Err(_) => {
4923 let _ = tx
4925 .send(InferenceEvent::ToolCallResult {
4926 id: call_id.clone(),
4927 name: "research_web".to_string(),
4928 result: "No results found — model will attempt its own search.".into(),
4929 is_error: true,
4930 })
4931 .await;
4932 }
4933 }
4934 }
4935
4936 if loop_intervention.is_none() {
4943 if let Some(fix_ctx) = self.pending_fix_context.take() {
4944 loop_intervention = Some(format!(
4945 "FIX MODE — The build is currently failing. Fix ONLY the error below. \
4946 Do not refactor, add features, or touch unrelated code. \
4947 After each edit call `verify_build` to check if the error is resolved. \
4948 Stop as soon as the build is green.\n\n\
4949 ## Current Build Error\n```\n{}\n```",
4950 fix_ctx.trim()
4951 ));
4952 }
4953 }
4954
4955 if loop_intervention.is_none() && needs_github_ops(&effective_user_input) {
4956 loop_intervention = Some(
4957 "GITHUB TOOL NOTICE: This query is about GitHub (PRs, issues, CI runs, or checks). \
4958 Use the `github_ops` tool — never call `gh` via `shell`. \
4959 For a quick overview, try `/pr` (PR status), `/ci` (CI status), or `/issue` (issues). \
4960 The model should call `github_ops` with the appropriate `action` field."
4961 .to_string(),
4962 );
4963 }
4964
4965 if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
4966 loop_intervention = Some(
4967 "COMPUTATION INTEGRITY NOTICE: This query requires a real numeric result. \
4968 You MUST NOT answer from training-data memory — that is a hallucination. \
4969 TOOL SELECTION: \
4970 • Use `run_code` for direct computation: arithmetic, percentages, unit conversion, \
4971 date math, statistics on given numbers, hashes. \
4972 Pass `language: \"python\"` for Python; omit or pass `language: \"javascript\"` for JS/Deno. \
4973 • Use `scientific_compute` for: symbolic algebra/calculus (mode: \"symbolic\"), \
4974 dimensional unit safety (mode: \"units\"), Big-O auditing (mode: \"complexity\"), \
4975 SQL/Python analysis of a CSV/JSON/SQLite file (mode: \"dataset\"). \
4976 RULE: every number in your response must come from tool output, not your weights. \
4977 Write the code, run it, show the result."
4978 .to_string(),
4979 );
4980 }
4981
4982 if loop_intervention.is_none() && intent.surgical_filesystem_mode {
4984 loop_intervention = Some(
4985 "NATIVE TOOL MANDATE: Your request involves local directory or file creation. \
4986 You MUST use Hematite's native surgical tools (`create_directory`, `write_file`, `update_file`, `patch_hunk`). \
4987 External `mcp__filesystem__*` mutation tools are BLOCKED for these actions and will fail. \
4988 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for 100% path accuracy."
4989 .to_string(),
4990 );
4991 }
4992
4993 if loop_intervention.is_none()
4998 && self.workflow_mode == WorkflowMode::Auto
4999 && is_scaffold_request(&effective_user_input)
5000 && !implement_current_plan
5001 {
5002 loop_intervention = Some(
5003 "AUTO-ARCHITECT: This request involves building multiple files (a scaffold). \
5004 Before implementing, draft a concise blueprint to `.hematite/PLAN.md` using `write_file`. \
5005 The blueprint should list:\n\
5006 1. The target directory path\n\
5007 2. Each file to create (with a one-line description of its purpose)\n\
5008 3. Key design decisions (e.g. color scheme, layout approach)\n\n\
5009 Use `@DESKTOP/`, `@DOCUMENTS/`, or `@DOWNLOADS/` sovereign tokens for path accuracy.\n\
5010 After writing the PLAN.md, respond with a brief summary of what you planned. \
5011 Do NOT start implementing yet — just write the plan."
5012 .to_string(),
5013 );
5014 }
5015
5016 let mut implementation_started = false;
5017 let mut plan_drafted_this_turn = false;
5018 let mut non_mutating_plan_steps = 0usize;
5019 let non_mutating_plan_soft_cap = 5usize;
5020 let non_mutating_plan_hard_cap = 8usize;
5021 let mut overview_runtime_trace: Option<String> = None;
5022
5023 let max_iters = 25;
5025 let mut consecutive_errors = 0;
5026 let mut empty_cleaned_nudges = 0u8;
5027 let mut first_iter = true;
5028 let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
5029 let _result_counts: std::collections::HashMap<String, usize> =
5031 std::collections::HashMap::new();
5032 let mut repeat_counts: std::collections::HashMap<String, usize> =
5034 std::collections::HashMap::with_capacity(8);
5035 let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
5036 std::collections::HashMap::with_capacity(8);
5037 let mut successful_read_targets: std::collections::HashSet<String> =
5038 std::collections::HashSet::with_capacity(8);
5039 let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
5041 std::collections::HashSet::with_capacity(8);
5042 let mut successful_grep_targets: std::collections::HashSet<String> =
5043 std::collections::HashSet::with_capacity(8);
5044 let mut no_match_grep_targets: std::collections::HashSet<String> =
5045 std::collections::HashSet::with_capacity(8);
5046 let mut broad_grep_targets: std::collections::HashSet<String> =
5047 std::collections::HashSet::with_capacity(8);
5048 let mut sovereign_task_root: Option<String> = None;
5049 let mut sovereign_scaffold_targets: std::collections::BTreeSet<String> =
5050 std::collections::BTreeSet::new();
5051 let mut turn_mutated_paths: std::collections::BTreeSet<String> =
5052 std::collections::BTreeSet::new();
5053 let mut mutation_counts_by_path: std::collections::HashMap<String, usize> =
5054 std::collections::HashMap::with_capacity(4);
5055 let mut frontend_polish_intervention_emitted = false;
5056 let mut visible_closeout_emitted = false;
5057
5058 let mut turn_anchor = self.history.len().saturating_sub(1);
5060
5061 {
5065 let context_length = self.engine.current_context_length();
5066 let vram_ratio = self.gpu_state.ratio();
5067 if compaction::should_compact(&self.history, context_length, vram_ratio) {
5068 let _ = tx
5069 .send(InferenceEvent::Thought(
5070 "Pre-turn compaction: context pressure detected — compacting history before inference.".into(),
5071 ))
5072 .await;
5073 if self
5074 .compact_history_if_needed(&tx, Some(turn_anchor))
5075 .await?
5076 {
5077 turn_anchor = self
5080 .history
5081 .iter()
5082 .rposition(|m| m.role == "user")
5083 .unwrap_or(self.history.len().saturating_sub(1));
5084 }
5085 }
5086 }
5087
5088 let _sleep_guard = crate::ui::sleep_inhibitor::SleepInhibitor::acquire();
5091
5092 let (budget_input_start, budget_output_start) = {
5094 let econ = self
5095 .engine
5096 .economics
5097 .lock()
5098 .unwrap_or_else(|p| p.into_inner());
5099 (econ.input_tokens, econ.output_tokens)
5100 };
5101 let budget_history_est: usize = self
5103 .history
5104 .iter()
5105 .take(turn_anchor)
5106 .map(crate::agent::inference::estimate_message_tokens)
5107 .sum();
5108 let mut budget_tool_costs: Vec<crate::agent::economics::ToolCost> = Vec::with_capacity(8);
5110
5111 for _iter in 0..max_iters {
5112 let context_prep_start = tokio::time::Instant::now();
5113 let mut mutation_occurred = false;
5114 if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
5116 self.cancel_token
5117 .store(false, std::sync::atomic::Ordering::SeqCst);
5118 let _ = tx
5119 .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
5120 .await;
5121 let _ = tx.send(InferenceEvent::Done).await;
5122 return Ok(());
5123 }
5124
5125 if self
5127 .compact_history_if_needed(&tx, Some(turn_anchor))
5128 .await?
5129 {
5130 turn_anchor = 2;
5133 }
5134
5135 let inject_vein = first_iter && !implement_current_plan;
5139 let messages = if implement_current_plan {
5140 first_iter = false;
5141 self.context_window_slice_from(turn_anchor)
5142 } else {
5143 first_iter = false;
5144 self.context_window_slice()
5145 };
5146
5147 let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
5151 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
5154 let mut msgs = vec![self.history[0].clone()];
5155 msgs.push(ChatMessage::system(&intervention));
5156 msgs
5157 } else {
5158 let merged =
5159 format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
5160 vec![ChatMessage::system(&merged)]
5161 }
5162 } else {
5163 vec![self.history[0].clone()]
5164 };
5165
5166 if inject_vein {
5170 if let Some(ctx) = vein_context.as_deref() {
5171 if crate::agent::inference::is_hematite_native_model(
5172 &self.engine.current_model(),
5173 ) {
5174 prompt_msgs.push(ChatMessage::system(ctx));
5175 } else {
5176 let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
5177 prompt_msgs[0] = ChatMessage::system(&merged);
5178 }
5179 }
5180 }
5181 if let Some(root) = sovereign_task_root.as_ref() {
5182 let sovereign_root_instruction = format!(
5183 "EFFECTIVE TASK ROOT: This sovereign scaffold turn is now rooted at:\n\
5184 `{root}`\n\n\
5185 Treat that directory as the active project root for the rest of this turn. \
5186 All reads, writes, verification, and summaries must stay scoped to that root. \
5187 Ignore unrelated repo context such as `./src` unless the user explicitly asks about it. \
5188 Keep building within this sovereign root instead of reasoning from the original workspace."
5189 );
5190 if crate::agent::inference::is_hematite_native_model(&self.engine.current_model()) {
5191 prompt_msgs.push(ChatMessage::system(&sovereign_root_instruction));
5192 } else {
5193 let merged = format!(
5194 "{}\n\n{}",
5195 prompt_msgs[0].content.as_str(),
5196 sovereign_root_instruction
5197 );
5198 prompt_msgs[0] = ChatMessage::system(&merged);
5199 }
5200 }
5201 prompt_msgs.extend(messages);
5202 if let Some(budget_note) =
5203 enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
5204 {
5205 self.emit_operator_checkpoint(
5206 &tx,
5207 OperatorCheckpointState::BudgetReduced,
5208 budget_note,
5209 )
5210 .await;
5211 let recipe = plan_recovery(
5212 RecoveryScenario::PromptBudgetPressure,
5213 &self.recovery_context,
5214 );
5215 self.emit_recovery_recipe_summary(
5216 &tx,
5217 recipe.recipe.scenario.label(),
5218 compact_recovery_plan_summary(&recipe),
5219 )
5220 .await;
5221 }
5222 self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
5223 .await;
5224
5225 let turn_tools = if yolo
5226 || (explicit_search_request && grounded_research_results.is_some())
5227 {
5228 Vec::new()
5230 } else if intent.sovereign_mode {
5231 self.tools
5232 .iter()
5233 .filter(|t| {
5234 t.function.name != "shell" && t.function.name != "run_workspace_workflow"
5235 })
5236 .cloned()
5237 .collect::<Vec<_>>()
5238 } else {
5239 self.tools.clone()
5240 };
5241
5242 let context_prep_ms = context_prep_start.elapsed().as_millis();
5243 let inference_start = tokio::time::Instant::now();
5244
5245 let explicit_search_synthesis = explicit_search_request
5246 && grounded_research_results.is_some()
5247 && turn_tools.is_empty();
5248
5249 let call_result = if explicit_search_synthesis {
5250 match tokio::time::timeout(
5251 tokio::time::Duration::from_secs(20),
5252 self.engine
5253 .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref()),
5254 )
5255 .await
5256 {
5257 Ok(result) => result,
5258 Err(_) => Err(
5259 "explicit_search_synthesis_timeout: grounded research summary took too long to complete"
5260 .to_string(),
5261 ),
5262 }
5263 } else {
5264 self.engine
5265 .call_with_tools(&prompt_msgs, &turn_tools, routed_model.as_deref())
5266 .await
5267 };
5268
5269 let (mut text, mut tool_calls, usage, finish_reason) = match call_result {
5270 Ok(result) => result,
5271 Err(e) => {
5272 if explicit_search_synthesis
5273 && (e.contains("explicit_search_synthesis_timeout")
5274 || e.contains("provider_degraded")
5275 || e.contains("empty response"))
5276 {
5277 if let Some(results) = grounded_research_results.as_deref() {
5278 let response = build_research_provider_fallback(results);
5279 self.history.push(ChatMessage::assistant_text(&response));
5280 self.transcript.log_agent(&response);
5281 let _ = tx
5282 .send(InferenceEvent::Thought(
5283 "Search synthesis stalled; returning a grounded fallback summary from the fetched results."
5284 .into(),
5285 ))
5286 .await;
5287 for chunk in chunk_text(&response, 8) {
5288 let _ = tx.send(InferenceEvent::Token(chunk)).await;
5289 }
5290 let _ = tx.send(InferenceEvent::Done).await;
5291 return Ok(());
5292 }
5293 }
5294
5295 let class = classify_runtime_failure(&e);
5296 if should_retry_runtime_failure(class)
5297 && self.recovery_context.consume_transient_retry()
5298 {
5299 let label = match class {
5300 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
5301 _ => "empty_model_response",
5302 };
5303 self.transcript.log_system(&format!(
5304 "Automatic provider recovery triggered: {}",
5305 e.trim()
5306 ));
5307 self.emit_recovery_recipe_summary(
5308 &tx,
5309 label,
5310 compact_runtime_recovery_summary(class),
5311 )
5312 .await;
5313 let _ = tx
5314 .send(InferenceEvent::ProviderStatus {
5315 state: ProviderRuntimeState::Recovering,
5316 summary: compact_runtime_recovery_summary(class).into(),
5317 })
5318 .await;
5319 self.emit_operator_checkpoint(
5320 &tx,
5321 OperatorCheckpointState::RecoveringProvider,
5322 compact_runtime_recovery_summary(class),
5323 )
5324 .await;
5325 continue;
5326 }
5327
5328 if explicit_search_request
5329 && matches!(
5330 class,
5331 RuntimeFailureClass::ProviderDegraded
5332 | RuntimeFailureClass::EmptyModelResponse
5333 )
5334 {
5335 if let Some(results) = grounded_research_results.as_deref() {
5336 let response = build_research_provider_fallback(results);
5337 self.history.push(ChatMessage::assistant_text(&response));
5338 self.transcript.log_agent(&response);
5339 for chunk in chunk_text(&response, 8) {
5340 let _ = tx.send(InferenceEvent::Token(chunk)).await;
5341 }
5342 let _ = tx.send(InferenceEvent::Done).await;
5343 return Ok(());
5344 }
5345 }
5346
5347 self.emit_runtime_failure(&tx, class, &e).await;
5348 break;
5349 }
5350 };
5351 let inference_ms = inference_start.elapsed().as_millis();
5352 let execution_start = tokio::time::Instant::now();
5353 self.emit_provider_live(&tx).await;
5354
5355 if text.is_none() && tool_calls.is_none() {
5360 if let Some(reasoning) = usage.as_ref().and_then(|u| {
5361 if u.completion_tokens > 2000 {
5362 Some(u.completion_tokens)
5363 } else {
5364 None
5365 }
5366 }) {
5367 self.emit_operator_checkpoint(
5368 &tx,
5369 OperatorCheckpointState::BlockedToolLoop,
5370 format!(
5371 "Reasoning collapse detected ({} tokens of empty output).",
5372 reasoning
5373 ),
5374 )
5375 .await;
5376 break;
5377 }
5378 }
5379
5380 if let Some(ref u) = usage {
5382 let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
5383 }
5384
5385 if tool_calls
5388 .as_ref()
5389 .map(|calls| calls.is_empty())
5390 .unwrap_or(true)
5391 {
5392 if let Some(raw_text) = text.as_deref() {
5393 let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
5394 if !native_calls.is_empty() {
5395 tool_calls = Some(native_calls);
5396 let stripped =
5397 crate::agent::inference::strip_native_tool_call_text(raw_text);
5398 text = if stripped.trim().is_empty() {
5399 None
5400 } else {
5401 Some(stripped)
5402 };
5403 }
5404 }
5405 }
5406
5407 let tool_calls = tool_calls.filter(|c| !c.is_empty());
5410 let near_context_ceiling = usage
5411 .as_ref()
5412 .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
5413 .unwrap_or(false);
5414
5415 if let Some(calls) = tool_calls {
5416 let (calls, prune_trace_note) =
5417 prune_architecture_trace_batch(calls, architecture_overview_mode);
5418 if let Some(note) = prune_trace_note {
5419 let _ = tx.send(InferenceEvent::Thought(note)).await;
5420 }
5421
5422 let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
5423 calls,
5424 self.workflow_mode.is_read_only(),
5425 architecture_overview_mode,
5426 );
5427 if let Some(note) = prune_bloat_note {
5428 let _ = tx.send(InferenceEvent::Thought(note)).await;
5429 }
5430
5431 let (calls, prune_note) = prune_authoritative_tool_batch(
5432 calls,
5433 grounded_trace_mode,
5434 &effective_user_input,
5435 );
5436 if let Some(note) = prune_note {
5437 let _ = tx.send(InferenceEvent::Thought(note)).await;
5438 }
5439
5440 let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
5441 if let Some(note) = prune_redir_note {
5442 let _ = tx.send(InferenceEvent::Thought(note)).await;
5443 }
5444
5445 let (calls, batch_note) = order_batch_reads_first(calls);
5446 if let Some(note) = batch_note {
5447 let _ = tx.send(InferenceEvent::Thought(note)).await;
5448 }
5449
5450 if let Some(repeated_path) = calls
5451 .iter()
5452 .filter_map(|c| repeated_read_target(&c.function))
5453 .find(|path| successful_read_targets.contains(path))
5454 {
5455 let repeated_path = repeated_path.to_string();
5456
5457 let err_msg = format!(
5458 "Read discipline: You already read `{}` recently. Use `inspect_lines` on a specific window or `grep_files` to find content, then continue with your edit.",
5459 repeated_path
5460 );
5461 let _ = tx
5462 .clone()
5463 .send(InferenceEvent::Token(format!("\n⚠️ {}\n", err_msg)))
5464 .await;
5465 let _ = tx
5466 .clone()
5467 .send(InferenceEvent::Thought(format!(
5468 "Intervention: {}",
5469 err_msg
5470 )))
5471 .await;
5472
5473 for call in &calls {
5476 self.history.push(ChatMessage::tool_result_for_model(
5477 &call.id,
5478 &call.function.name,
5479 &err_msg,
5480 &self.engine.current_model(),
5481 ));
5482 }
5483 self.emit_done_events(&tx).await;
5484 return Ok(());
5485 }
5486
5487 if capability_mode
5488 && !capability_needs_repo
5489 && calls
5490 .iter()
5491 .all(|c| is_capability_probe_tool(&c.function.name))
5492 {
5493 loop_intervention = Some(
5494 "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
5495 Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
5496 Do not mention raw `mcp__*` names unless they are active and directly relevant."
5497 .to_string(),
5498 );
5499 let _ = tx.clone()
5500 .send(InferenceEvent::Thought(
5501 "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
5502 .into(),
5503 ))
5504 .await;
5505 continue;
5506 }
5507
5508 let raw_content = text.as_deref().unwrap_or(" ");
5511
5512 if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
5513 let _ = tx
5514 .clone()
5515 .send(InferenceEvent::Thought(thought.clone()))
5516 .await;
5517 self.reasoning_history = Some(thought);
5519 }
5520
5521 let stored_tool_call_content = if implement_current_plan {
5524 cap_output(raw_content, 1200)
5525 } else {
5526 raw_content.to_string()
5527 };
5528 self.history.push(ChatMessage::assistant_tool_calls(
5529 &stored_tool_call_content,
5530 calls.clone(),
5531 ));
5532
5533 let mut results = Vec::with_capacity(calls.len());
5535 let gemma4_model =
5536 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
5537 let latest_user_prompt = self.latest_user_prompt();
5538 let mut seen_call_keys = std::collections::HashSet::new();
5539 let mut deduped_calls = Vec::with_capacity(calls.len());
5540 for call in calls.clone() {
5541 let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
5542 &call.function.name,
5543 &call.function.arguments,
5544 gemma4_model,
5545 latest_user_prompt,
5546 );
5547
5548 if crate::agent::policy::is_destructive_tool(&normalized_name) {
5550 if let Some(path) = crate::agent::policy::tool_path_argument(
5551 &normalized_name,
5552 &normalized_args,
5553 ) {
5554 let tracker = self.diff_tracker.clone();
5555 tokio::spawn(async move {
5556 let mut guard = tracker.lock().await;
5557 guard.on_file_access(std::path::Path::new(&path));
5558 });
5559 }
5560 }
5561
5562 if normalized_name == "shell" || normalized_name == "run_workspace_workflow" {
5564 let cmd_val = normalized_args
5565 .get("command")
5566 .or_else(|| normalized_args.get("workflow"));
5567
5568 if let Some(cmd) = cmd_val.and_then(|v| v.as_str()) {
5569 if cfg!(windows)
5570 && (cmd.contains("/dev/")
5571 || cmd.contains("/etc/")
5572 || cmd.contains("/var/"))
5573 {
5574 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.";
5575 let _ = tx
5576 .clone()
5577 .send(InferenceEvent::Token(format!("\n🚨 {}\n", err_msg)))
5578 .await;
5579 let _ = tx
5580 .clone()
5581 .send(InferenceEvent::Thought(format!(
5582 "Panic blocked: {}",
5583 err_msg
5584 )))
5585 .await;
5586
5587 let mut err_results = Vec::with_capacity(calls.len());
5589 for c in &calls {
5590 err_results.push(ChatMessage::tool_result_for_model(
5591 &c.id,
5592 &c.function.name,
5593 err_msg,
5594 &self.engine.current_model(),
5595 ));
5596 }
5597 for res in err_results {
5598 self.history.push(res);
5599 }
5600 self.emit_done_events(&tx).await;
5601 return Ok(());
5602 }
5603
5604 if is_natural_language_hallucination(cmd) {
5605 let err_msg = format!(
5606 "HALLUCINATION BLOCKED: You tried to pass natural language ('{}') into a command field. \
5607 Commands must be literal executables (e.g. `npm install`, `mkdir path`). \
5608 Use the correct surgical tool (like `create_directory`) instead of overthinking.",
5609 cmd
5610 );
5611 let _ = tx
5612 .send(InferenceEvent::Thought(format!(
5613 "Sanitizer error: {}",
5614 err_msg
5615 )))
5616 .await;
5617 results.push(ToolExecutionOutcome {
5618 call_id: call.id.clone(),
5619 tool_name: normalized_name.clone(),
5620 args: normalized_args.clone(),
5621 output: err_msg,
5622 is_error: true,
5623 blocked_by_policy: false,
5624 msg_results: Vec::new(),
5625 latest_target_dir: None,
5626 plan_drafted_this_turn: false,
5627 parsed_plan_handoff: None,
5628 });
5629 continue;
5630 }
5631 }
5632 }
5633
5634 let key = canonical_tool_call_key(&normalized_name, &normalized_args);
5635 if seen_call_keys.insert(key) {
5636 let repeat_guard_exempt = matches!(
5637 normalized_name.as_str(),
5638 "verify_build" | "git_commit" | "git_push"
5639 );
5640 if !repeat_guard_exempt {
5641 if let Some(cached) = completed_tool_cache
5642 .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
5643 {
5644 let _ = tx
5645 .send(InferenceEvent::Thought(
5646 "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
5647 .to_string(),
5648 ))
5649 .await;
5650 loop_intervention = Some(format!(
5651 "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.",
5652 cached.tool_name
5653 ));
5654 continue;
5655 }
5656 }
5657 deduped_calls.push(call);
5658 } else {
5659 let _ = tx
5660 .send(InferenceEvent::Thought(
5661 "Duplicate tool call skipped: identical built-in invocation already ran this turn."
5662 .to_string(),
5663 ))
5664 .await;
5665 }
5666 }
5667
5668 let total_used = usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
5671 let ctx_len = self.engine.current_context_length();
5672 let remaining = ctx_len.saturating_sub(total_used);
5673 let tool_budget = remaining.saturating_sub(3000);
5674 let budget_per_call = if deduped_calls.is_empty() {
5675 0
5676 } else {
5677 tool_budget / deduped_calls.len().max(1)
5678 };
5679
5680 let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
5682 .into_iter()
5683 .partition(|c| is_parallel_safe(&c.function.name));
5684
5685 if !parallel_calls.is_empty() {
5687 let mut tasks = Vec::with_capacity(parallel_calls.len());
5688 for call in parallel_calls {
5689 let tx_clone = tx.clone();
5690 let config_clone = config.clone();
5691 let call_with_id = call.clone();
5693 tasks.push(self.process_tool_call(
5694 call_with_id.function,
5695 config_clone,
5696 yolo,
5697 tx_clone,
5698 call_with_id.id,
5699 budget_per_call,
5700 ));
5701 }
5702 results.extend(futures::future::join_all(tasks).await);
5704 }
5705
5706 let mut sovereign_bootstrap_complete = false;
5708
5709 for call in serial_calls {
5710 let outcome = self
5711 .process_tool_call(
5712 call.function,
5713 config.clone(),
5714 yolo,
5715 tx.clone(),
5716 call.id,
5717 budget_per_call,
5718 )
5719 .await;
5720
5721 if !outcome.is_error {
5722 let tool_name = outcome.tool_name.as_str();
5723 if matches!(
5724 tool_name,
5725 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5726 ) {
5727 if let Some(target) = action_target_path(tool_name, &outcome.args) {
5728 let normalized_path = normalize_workspace_path(&target);
5729 let rewrite_count = mutation_counts_by_path
5730 .entry(normalized_path.clone())
5731 .and_modify(|count| *count += 1)
5732 .or_insert(1);
5733
5734 let is_frontend_asset = [
5735 ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue",
5736 ".svelte",
5737 ]
5738 .iter()
5739 .any(|ext| normalized_path.ends_with(ext));
5740
5741 if is_frontend_asset && *rewrite_count >= 3 {
5742 frontend_polish_intervention_emitted = true;
5743 loop_intervention = Some(format!(
5744 "REWRITE LIMIT REACHED. You have updated `{}` {} times this turn. To prevent reasoning collapse, further rewrites to this file are blocked. \
5745 Please UPDATE `.hematite/TASK.md` to check off these completed steps, and response with a concise engineering summary of the implementation status.",
5746 normalized_path, rewrite_count
5747 ));
5748 results.push(outcome);
5749 let _ = tx.send(InferenceEvent::Thought("Frontend rewrite guard: block reached — prompting for task update and summary.".to_string())).await;
5750 break; } else if !frontend_polish_intervention_emitted
5752 && is_frontend_asset
5753 && *rewrite_count >= 2
5754 {
5755 frontend_polish_intervention_emitted = true;
5756 loop_intervention = Some(format!(
5757 "STOP REWRITING. You have already written `{}` {} times. The current version is sufficient as a foundation. \
5758 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.",
5759 normalized_path, rewrite_count
5760 ));
5761 results.push(outcome);
5762 let _ = tx.send(InferenceEvent::Thought("Frontend polish guard: repeated rewrite detected; prompting for progress log and next steps.".to_string())).await;
5763 break; }
5765 }
5766 }
5767 }
5768
5769 if !outcome.is_error
5770 && intent.sovereign_mode
5771 && is_scaffold_request(&effective_user_input)
5772 && outcome.latest_target_dir.is_some()
5773 {
5774 sovereign_bootstrap_complete = true;
5775 }
5776 results.push(outcome);
5777 if sovereign_bootstrap_complete {
5778 let _ = tx
5779 .send(InferenceEvent::Thought(
5780 "Sovereign scaffold bootstrap complete: stopping this session after root setup so the resumed session can continue inside the new project."
5781 .to_string(),
5782 ))
5783 .await;
5784 break;
5785 }
5786 }
5787
5788 let execution_ms = execution_start.elapsed().as_millis();
5789 let _ = tx
5790 .send(InferenceEvent::TurnTiming {
5791 context_prep_ms,
5792 inference_ms,
5793 execution_ms,
5794 })
5795 .await;
5796
5797 let mut authoritative_tool_output: Option<String> = None;
5799 let mut blocked_policy_output: Option<String> = None;
5800 let mut recoverable_policy_intervention: Option<String> = None;
5801 let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
5802 let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
5803 None;
5804 for res in results {
5805 let call_id = res.call_id.clone();
5806 let tool_name = res.tool_name.clone();
5807 let final_output = res.output.clone();
5808 let is_error = res.is_error;
5809 for msg in res.msg_results {
5810 self.history.push(msg);
5811 }
5812
5813 if let Some(path) = res.latest_target_dir {
5815 if intent.sovereign_mode && sovereign_task_root.is_none() {
5816 sovereign_task_root = Some(path.clone());
5817 self.pending_teleport_handoff = Some(SovereignTeleportHandoff {
5818 root: path.clone(),
5819 plan: build_sovereign_scaffold_handoff(
5820 &effective_user_input,
5821 &sovereign_scaffold_targets,
5822 ),
5823 });
5824 let _ = tx
5825 .send(InferenceEvent::Thought(format!(
5826 "Sovereign scaffold root established at `{}`; rebinding project context there for the rest of this turn.",
5827 path
5828 )))
5829 .await;
5830 }
5831 self.latest_target_dir = Some(path);
5832 }
5833
5834 if intent.sovereign_mode && is_scaffold_request(&effective_user_input) {
5835 if let Some(root) = sovereign_task_root.as_ref() {
5836 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
5837 let resolved = crate::tools::file_ops::resolve_candidate(path);
5838 let root_path = std::path::Path::new(root);
5839 if let Ok(relative) = resolved.strip_prefix(root_path) {
5840 if !relative.as_os_str().is_empty() {
5841 sovereign_scaffold_targets
5842 .insert(relative.to_string_lossy().replace('\\', "/"));
5843 }
5844 self.pending_teleport_handoff =
5845 Some(SovereignTeleportHandoff {
5846 root: root.clone(),
5847 plan: build_sovereign_scaffold_handoff(
5848 &effective_user_input,
5849 &sovereign_scaffold_targets,
5850 ),
5851 });
5852 }
5853 }
5854 }
5855 }
5856 if matches!(
5857 tool_name.as_str(),
5858 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5859 ) {
5860 mutation_occurred = true;
5861 implementation_started = true;
5862 if !is_error {
5863 if let Some(target) = action_target_path(&tool_name, &res.args) {
5864 turn_mutated_paths.insert(target);
5865 }
5866 }
5867 if !is_error {
5869 let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
5870 if !path.is_empty() {
5871 self.vein.bump_heat(path);
5872 self.l1_context = self.vein.l1_context();
5873 compact_stale_reads(&mut self.history, path);
5876 }
5877 self.refresh_repo_map();
5879 }
5880 }
5881
5882 if !is_error
5883 && matches!(
5884 tool_name.as_str(),
5885 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
5886 )
5887 {
5888 }
5890
5891 if res.plan_drafted_this_turn {
5892 plan_drafted_this_turn = true;
5893 }
5894 if let Some(plan) = res.parsed_plan_handoff.clone() {
5895 self.session_memory.current_plan = Some(plan);
5896 }
5897
5898 if tool_name == "verify_build" {
5899 self.record_session_verification(
5900 !is_error
5901 && (final_output.contains("BUILD OK")
5902 || final_output.contains("BUILD SUCCESS")
5903 || final_output.contains("BUILD OKAY")),
5904 if is_error {
5905 "Explicit verify_build failed."
5906 } else {
5907 "Explicit verify_build passed."
5908 },
5909 );
5910 }
5911
5912 let call_key = format!(
5914 "{}:{}",
5915 tool_name,
5916 serde_json::to_string(&res.args).unwrap_or_default()
5917 );
5918 let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
5919 *repeat_count += 1;
5920
5921 let repeat_guard_exempt =
5924 is_repeat_guard_exempt_tool_call(&tool_name, &res.args);
5925 if *repeat_count >= 2 && !repeat_guard_exempt {
5926 loop_intervention = Some(format!(
5927 "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
5928 Do not call it again. Either answer directly from what you already know, \
5929 use a different tool or approach (e.g. if reading the same file, use grep or LSP symbols instead), \
5930 or ask the user for clarification.",
5931 tool_name, *repeat_count
5932 ));
5933 let _ = tx
5934 .send(InferenceEvent::Thought(format!(
5935 "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
5936 tool_name, *repeat_count
5937 )))
5938 .await;
5939 }
5940
5941 if *repeat_count >= 3 && !repeat_guard_exempt {
5942 self.emit_runtime_failure(
5943 &tx,
5944 RuntimeFailureClass::ToolLoop,
5945 &format!(
5946 "STRICT: You are stuck in a reasoning loop calling `{}`. \
5947 STOP repeating this call. Switch to grounded filesystem tools \
5948 (like `read_file`, `inspect_lines`, or `edit_file`) instead of \
5949 attempting this workflow again.",
5950 tool_name
5951 ),
5952 )
5953 .await;
5954 return Ok(());
5955 }
5956
5957 if is_error {
5958 consecutive_errors += 1;
5959 } else {
5960 consecutive_errors = 0;
5961 }
5962
5963 if consecutive_errors >= 3 {
5964 loop_intervention = Some(
5965 "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
5966 STOP all tool calls immediately. Analyze why your previous 3 calls failed \
5967 (check for hallucinations or invalid arguments) and ask the user for \
5968 clarification if you cannot proceed.".to_string()
5969 );
5970 }
5971
5972 if consecutive_errors >= 4 {
5973 self.emit_runtime_failure(
5974 &tx,
5975 RuntimeFailureClass::ToolLoop,
5976 "Hard termination: too many consecutive tool errors.",
5977 )
5978 .await;
5979 return Ok(());
5980 }
5981
5982 if !should_suppress_recoverable_tool_result(
5983 res.blocked_by_policy,
5984 recoverable_policy_intervention.is_some(),
5985 ) {
5986 let _ = tx
5987 .send(InferenceEvent::ToolCallResult {
5988 id: call_id.clone(),
5989 name: tool_name.clone(),
5990 result: final_output.clone(),
5991 is_error,
5992 })
5993 .await;
5994 }
5995
5996 let repeat_guard_exempt = matches!(
5997 tool_name.as_str(),
5998 "verify_build" | "git_commit" | "git_push"
5999 );
6000 if !repeat_guard_exempt {
6001 completed_tool_cache.insert(
6002 canonical_tool_call_key(&tool_name, &res.args),
6003 CachedToolResult {
6004 tool_name: tool_name.clone(),
6005 },
6006 );
6007 }
6008
6009 let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
6011 self.engine.current_context_length(),
6012 );
6013 let capped = if implement_current_plan {
6014 cap_output(&final_output, 1200)
6015 } else if compact_ctx
6016 && (tool_name == "read_file" || tool_name == "inspect_lines")
6017 {
6018 let limit = 3000usize;
6020 if final_output.len() > limit {
6021 let total_lines = final_output.lines().count();
6022 let mut split_at = limit;
6023 while !final_output.is_char_boundary(split_at) && split_at > 0 {
6024 split_at -= 1;
6025 }
6026 let scratch = write_output_to_scratch(&final_output, &tool_name)
6027 .map(|p| format!(" Full file also saved to '{p}'."))
6028 .unwrap_or_default();
6029 format!(
6030 "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
6031 &final_output[..split_at],
6032 total_lines,
6033 total_lines.saturating_sub(150),
6034 scratch,
6035 )
6036 } else {
6037 final_output.clone()
6038 }
6039 } else {
6040 cap_output_for_tool(&final_output, 8000, &tool_name)
6041 };
6042 self.history.push(ChatMessage::tool_result_for_model(
6043 &call_id,
6044 &tool_name,
6045 &capped,
6046 &self.engine.current_model(),
6047 ));
6048 budget_tool_costs.push(crate::agent::economics::ToolCost {
6049 name: tool_name.clone(),
6050 tokens: capped.len() / 4,
6051 });
6052
6053 if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
6054 {
6055 overview_runtime_trace =
6056 Some(summarize_runtime_trace_output(&final_output));
6057 }
6058
6059 if !architecture_overview_mode
6060 && !is_error
6061 && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
6062 || (toolchain_mode && tool_name == "describe_toolchain"))
6063 {
6064 authoritative_tool_output = Some(final_output.clone());
6065 }
6066
6067 if !is_error && tool_name == "read_file" {
6068 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
6069 let normalized = normalize_workspace_path(path);
6070 let read_offset =
6071 res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
6072 successful_read_targets.insert(normalized.clone());
6073 successful_read_regions.insert((normalized.clone(), read_offset));
6074 }
6075 }
6076
6077 if !is_error && tool_name == "grep_files" {
6078 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
6079 let normalized = normalize_workspace_path(path);
6080 if final_output.starts_with("No matches for ") {
6081 no_match_grep_targets.insert(normalized);
6082 } else if grep_output_is_high_fanout(&final_output) {
6083 broad_grep_targets.insert(normalized);
6084 } else {
6085 successful_grep_targets.insert(normalized);
6086 }
6087 }
6088 }
6089
6090 if is_error
6091 && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
6092 && (final_output.contains("search string not found")
6093 || final_output.contains("search string is too short")
6094 || final_output.contains("search string matched"))
6095 {
6096 if let Some(target) = action_target_path(&tool_name, &res.args) {
6097 let guidance = if final_output.contains("matched") {
6098 let snippet = read_file_preview_for_retry(&target, 120);
6101 format!(
6102 "EDIT FAILED — search string matched multiple locations in `{target}`. \
6103 You need a longer, more unique search string that includes surrounding context.\n\
6104 Current file content (first 120 lines):\n```\n{snippet}\n```\n\
6105 Retry `{tool_name}` with a search string that is unique in the file."
6106 )
6107 } else {
6108 let snippet = read_file_preview_for_retry(&target, 200);
6111 let normalized = normalize_workspace_path(&target);
6114 {
6115 let mut ag = self.action_grounding.lock().await;
6116 let turn = ag.turn_index;
6117 ag.observed_paths.insert(normalized.clone(), turn);
6118 ag.inspected_paths.insert(normalized, turn);
6119 }
6120 format!(
6121 "EDIT FAILED — search string did not match any text in `{target}`.\n\
6122 The model must have generated text that differs from what is actually in the file \
6123 (wrong whitespace, indentation, or stale content).\n\
6124 Current file content (up to 200 lines shown):\n```\n{snippet}\n```\n\
6125 Find the exact line(s) to change above, copy the text character-for-character \
6126 (preserving indentation), and immediately retry `{tool_name}` \
6127 with that exact text as the search string. Do NOT call read_file again — \
6128 the content is already shown above."
6129 )
6130 };
6131 loop_intervention = Some(guidance);
6132 *repeat_count = 0;
6133 }
6134 }
6135
6136 if is_error
6139 && tool_name == "shell"
6140 && final_output.contains("Use the run_code tool instead")
6141 && loop_intervention.is_none()
6142 {
6143 loop_intervention = Some(
6144 "STOP. Shell was blocked because this is a computation task. \
6145 You MUST use `run_code` now — write the code and run it. \
6146 Do NOT output an error message or give up. \
6147 Call `run_code` with the appropriate language and code to compute the answer. \
6148 If writing Python, pass `language: \"python\"`. \
6149 If writing JavaScript, omit language or pass `language: \"javascript\"`."
6150 .to_string(),
6151 );
6152 }
6153
6154 if is_error
6157 && tool_name == "run_code"
6158 && (final_output.contains("source code could not be parsed")
6159 || final_output.contains("Expected ';'")
6160 || final_output.contains("Expected '}'")
6161 || final_output.contains("is not defined")
6162 && final_output.contains("deno"))
6163 && loop_intervention.is_none()
6164 {
6165 loop_intervention = Some(
6166 "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
6167 code but forgot to pass `language: \"python\"`. \
6168 Retry run_code with `language: \"python\"` and the same code. \
6169 Do NOT fall back to shell. Do NOT give up."
6170 .to_string(),
6171 );
6172 }
6173
6174 if res.blocked_by_policy
6175 && is_mcp_workspace_read_tool(&tool_name)
6176 && recoverable_policy_intervention.is_none()
6177 {
6178 recoverable_policy_intervention = Some(
6179 "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
6180 );
6181 recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
6182 recoverable_policy_checkpoint = Some((
6183 OperatorCheckpointState::BlockedPolicy,
6184 "MCP workspace read blocked; rerouting to built-in file tools."
6185 .to_string(),
6186 ));
6187 } else if res.blocked_by_policy
6188 && implement_current_plan
6189 && is_current_plan_irrelevant_tool(&tool_name)
6190 && recoverable_policy_intervention.is_none()
6191 {
6192 recoverable_policy_intervention = Some(format!(
6193 "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
6194 tool_name
6195 ));
6196 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6197 recoverable_policy_checkpoint = Some((
6198 OperatorCheckpointState::BlockedPolicy,
6199 format!(
6200 "Current-plan execution blocked unrelated tool `{}`.",
6201 tool_name
6202 ),
6203 ));
6204 } else if res.blocked_by_policy
6205 && implement_current_plan
6206 && final_output
6207 .contains("current-plan execution is locked to the saved target files")
6208 && recoverable_policy_intervention.is_none()
6209 {
6210 let target_files = self
6211 .session_memory
6212 .current_plan
6213 .as_ref()
6214 .map(|plan| plan.target_files.clone())
6215 .unwrap_or_default();
6216 recoverable_policy_intervention =
6217 Some(build_current_plan_scope_recovery_prompt(&target_files));
6218 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
6219 recoverable_policy_checkpoint = Some((
6220 OperatorCheckpointState::BlockedPolicy,
6221 format!(
6222 "Current-plan execution blocked off-target path access via `{}`.",
6223 tool_name
6224 ),
6225 ));
6226 } else if res.blocked_by_policy
6227 && implement_current_plan
6228 && final_output.contains("requires recent file evidence")
6229 && recoverable_policy_intervention.is_none()
6230 {
6231 let target = action_target_path(&tool_name, &res.args)
6232 .unwrap_or_else(|| "the target file".to_string());
6233 recoverable_policy_intervention = Some(format!(
6234 "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
6235 ));
6236 recoverable_policy_recipe =
6237 Some(RecoveryScenario::RecentFileEvidenceMissing);
6238 recoverable_policy_checkpoint = Some((
6239 OperatorCheckpointState::BlockedRecentFileEvidence,
6240 format!("Edit blocked on `{target}`; recent file evidence missing."),
6241 ));
6242 } else if res.blocked_by_policy
6243 && implement_current_plan
6244 && final_output.contains("requires an exact local line window first")
6245 && recoverable_policy_intervention.is_none()
6246 {
6247 let target = action_target_path(&tool_name, &res.args)
6248 .unwrap_or_else(|| "the target file".to_string());
6249 recoverable_policy_intervention = Some(format!(
6250 "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
6251 ));
6252 recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
6253 recoverable_policy_checkpoint = Some((
6254 OperatorCheckpointState::BlockedExactLineWindow,
6255 format!("Edit blocked on `{target}`; exact line window required."),
6256 ));
6257 } else if res.blocked_by_policy
6258 && (final_output.contains("Prefer `")
6259 || final_output.contains("Prefer tool"))
6260 && recoverable_policy_intervention.is_none()
6261 {
6262 recoverable_policy_intervention = Some(final_output.clone());
6263 recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
6264 recoverable_policy_checkpoint = Some((
6265 OperatorCheckpointState::BlockedPolicy,
6266 "Action blocked by policy; self-correction triggered using tool recommendation."
6267 .to_string(),
6268 ));
6269 } else if res.blocked_by_policy && blocked_policy_output.is_none() {
6270 blocked_policy_output = Some(final_output.clone());
6271 }
6272
6273 if *repeat_count >= 5 {
6274 let _ = tx.send(InferenceEvent::Done).await;
6275 return Ok(());
6276 }
6277
6278 if implement_current_plan
6279 && !implementation_started
6280 && !is_error
6281 && is_non_mutating_plan_step_tool(&tool_name)
6282 {
6283 non_mutating_plan_steps += 1;
6284 }
6285 }
6286
6287 if sovereign_bootstrap_complete
6288 && intent.sovereign_mode
6289 && is_scaffold_request(&effective_user_input)
6290 {
6291 let response = if let Some(root) = sovereign_task_root.as_deref() {
6292 format!(
6293 "Project root created at `{root}`. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6294 )
6295 } else {
6296 "Project root created. Teleporting into the new project now so Hematite can continue there with a fresh local handoff."
6297 .to_string()
6298 };
6299 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
6300 .await;
6301 return Ok(());
6302 }
6303
6304 if let Some(intervention) = recoverable_policy_intervention {
6305 if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
6306 self.emit_operator_checkpoint(&tx, state, summary).await;
6307 }
6308 if let Some(scenario) = recoverable_policy_recipe.take() {
6309 let recipe = plan_recovery(scenario, &self.recovery_context);
6310 self.emit_recovery_recipe_summary(
6311 &tx,
6312 recipe.recipe.scenario.label(),
6313 compact_recovery_plan_summary(&recipe),
6314 )
6315 .await;
6316 }
6317 loop_intervention = Some(intervention);
6318 let _ = tx
6319 .send(InferenceEvent::Thought(
6320 "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
6321 .into(),
6322 ))
6323 .await;
6324 continue;
6325 }
6326
6327 if architecture_overview_mode {
6328 match overview_runtime_trace.as_deref() {
6329 Some(runtime_trace) => {
6330 let response = build_architecture_overview_answer(runtime_trace);
6331 self.history.push(ChatMessage::assistant_text(&response));
6332 self.transcript.log_agent(&response);
6333
6334 for chunk in chunk_text(&response, 8) {
6335 if !chunk.is_empty() {
6336 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6337 }
6338 }
6339
6340 let _ = tx.send(InferenceEvent::Done).await;
6341 break;
6342 }
6343 None => {
6344 loop_intervention = Some(
6345 "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."
6346 .to_string(),
6347 );
6348 continue;
6349 }
6350 }
6351 }
6352
6353 if implement_current_plan
6354 && !implementation_started
6355 && non_mutating_plan_steps >= non_mutating_plan_hard_cap
6356 {
6357 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();
6358 self.history.push(ChatMessage::assistant_text(&msg));
6359 self.transcript.log_agent(&msg);
6360
6361 for chunk in chunk_text(&msg, 8) {
6362 if !chunk.is_empty() {
6363 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6364 }
6365 }
6366
6367 let _ = tx.send(InferenceEvent::Done).await;
6368 break;
6369 }
6370
6371 if let Some(blocked_output) = blocked_policy_output {
6372 self.emit_operator_checkpoint(
6373 &tx,
6374 OperatorCheckpointState::BlockedPolicy,
6375 "A blocked tool path was surfaced directly to the operator.",
6376 )
6377 .await;
6378 self.history
6379 .push(ChatMessage::assistant_text(&blocked_output));
6380 self.transcript.log_agent(&blocked_output);
6381
6382 for chunk in chunk_text(&blocked_output, 8) {
6383 if !chunk.is_empty() {
6384 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6385 }
6386 }
6387
6388 let _ = tx.send(InferenceEvent::Done).await;
6389 break;
6390 }
6391
6392 if let Some(tool_output) = authoritative_tool_output {
6393 self.history.push(ChatMessage::assistant_text(&tool_output));
6394 self.transcript.log_agent(&tool_output);
6395
6396 for chunk in chunk_text(&tool_output, 8) {
6397 if !chunk.is_empty() {
6398 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6399 }
6400 }
6401
6402 let _ = tx.send(InferenceEvent::Done).await;
6403 break;
6404 }
6405
6406 if implement_current_plan && !implementation_started {
6407 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.";
6408 if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
6409 loop_intervention = Some(format!(
6410 "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
6411 base
6412 ));
6413 } else {
6414 loop_intervention = Some(base.to_string());
6415 }
6416 } else if self.workflow_mode == WorkflowMode::Architect {
6417 loop_intervention = Some(
6418 format!(
6419 "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.",
6420 architect_handoff_contract()
6421 ),
6422 );
6423 }
6424
6425 if mutation_occurred && !yolo && !intent.sovereign_mode {
6427 let _ = tx
6428 .send(InferenceEvent::Thought(
6429 "Self-Verification: Running contract-aware workspace verification..."
6430 .into(),
6431 ))
6432 .await;
6433 let verify_outcome = self.auto_verify_workspace(&turn_mutated_paths).await;
6434 let verify_res = verify_outcome.summary;
6435 let verify_ok = verify_outcome.ok;
6436 self.record_verify_build_result(verify_ok, &verify_res)
6437 .await;
6438 self.record_session_verification(
6439 verify_ok,
6440 if verify_ok {
6441 "Automatic workspace verification passed."
6442 } else {
6443 "Automatic workspace verification failed."
6444 },
6445 );
6446 self.history.push(ChatMessage::system(&format!(
6447 "\n# SYSTEM VERIFICATION\n{verify_res}"
6448 )));
6449 let _ = tx
6450 .send(InferenceEvent::Thought(
6451 "Verification turn injected into history.".into(),
6452 ))
6453 .await;
6454 }
6455
6456 continue;
6458 } else if let Some(response_text) = text {
6459 if finish_reason.as_deref() == Some("length") && near_context_ceiling {
6460 if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
6461 let cleaned = build_session_reset_semantics_answer();
6462 self.history.push(ChatMessage::assistant_text(&cleaned));
6463 self.transcript.log_agent(&cleaned);
6464 for chunk in chunk_text(&cleaned, 8) {
6465 if !chunk.is_empty() {
6466 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6467 }
6468 }
6469 let _ = tx.send(InferenceEvent::Done).await;
6470 break;
6471 }
6472
6473 let warning = format_runtime_failure(
6474 RuntimeFailureClass::ContextWindow,
6475 "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.",
6476 );
6477 self.history.push(ChatMessage::assistant_text(&warning));
6478 self.transcript.log_agent(&warning);
6479 let _ = tx
6480 .send(InferenceEvent::Thought(
6481 "Length recovery: model hit the context ceiling before completing the answer."
6482 .into(),
6483 ))
6484 .await;
6485 for chunk in chunk_text(&warning, 8) {
6486 if !chunk.is_empty() {
6487 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6488 }
6489 }
6490 let _ = tx.send(InferenceEvent::Done).await;
6491 break;
6492 }
6493
6494 if response_text.contains("<|tool_call")
6495 || response_text.contains("[END_TOOL_REQUEST]")
6496 || response_text.contains("<|tool_response")
6497 || response_text.contains("<tool_response|>")
6498 {
6499 loop_intervention = Some(
6500 "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(),
6501 );
6502 continue;
6503 }
6504
6505 if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
6507 {
6508 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
6509 self.reasoning_history = Some(thought);
6512 }
6513
6514 let execution_ms = execution_start.elapsed().as_millis();
6515 let _ = tx
6516 .send(InferenceEvent::TurnTiming {
6517 context_prep_ms,
6518 inference_ms,
6519 execution_ms,
6520 })
6521 .await;
6522
6523 let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
6525
6526 if implement_current_plan && !implementation_started {
6527 loop_intervention = Some(
6528 "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(),
6529 );
6530 continue;
6531 }
6532
6533 if cleaned.is_empty() {
6539 empty_cleaned_nudges += 1;
6540 if empty_cleaned_nudges == 1 {
6541 loop_intervention = Some(
6542 "Your visible response was empty. The tool already returned data. \
6543 Write your answer now in plain text — no <think> tags, no tool calls. \
6544 State the key facts in 2-5 sentences and stop."
6545 .to_string(),
6546 );
6547 continue;
6548 } else if empty_cleaned_nudges == 2 {
6549 loop_intervention = Some(
6550 "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
6551 Write the answer in plain text right now. \
6552 Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
6553 .to_string(),
6554 );
6555 continue;
6556 }
6557 if let Some(summary) = maybe_deterministic_sovereign_closeout(
6558 self.session_memory.current_plan.as_ref(),
6559 mutation_occurred,
6560 ) {
6561 self.history.push(ChatMessage::assistant_text(&summary));
6562 self.transcript.log_agent(&summary);
6563 for chunk in chunk_text(&summary, 8) {
6564 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6565 }
6566 let _ = tx.send(InferenceEvent::Done).await;
6567 return Ok(());
6568 }
6569
6570 let last_was_tool = self
6571 .history
6572 .last()
6573 .map(|m| m.role == "tool")
6574 .unwrap_or(false);
6575 if last_was_tool {
6576 let fallback = "[Proof successful. See tool output above for results.]";
6577 self.history.push(ChatMessage::assistant_text(fallback));
6578 self.transcript.log_agent(fallback);
6579 for chunk in chunk_text(fallback, 8) {
6580 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6581 }
6582 let _ = tx.send(InferenceEvent::Done).await;
6583 return Ok(());
6584 }
6585
6586 self.emit_runtime_failure(
6587 &tx,
6588 RuntimeFailureClass::EmptyModelResponse,
6589 "Model returned empty content after 2 nudge attempts.",
6590 )
6591 .await;
6592 break;
6593 }
6594
6595 let architect_handoff = self.persist_architect_handoff(&cleaned);
6596 self.history.push(ChatMessage::assistant_text(&cleaned));
6597 self.transcript.log_agent(&cleaned);
6598 visible_closeout_emitted = true;
6599
6600 for chunk in chunk_text(&cleaned, 8) {
6602 if !chunk.is_empty() {
6603 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
6604 }
6605 }
6606
6607 if let Some(plan) = architect_handoff.as_ref() {
6608 let note = architect_handoff_operator_note(plan);
6609 self.history.push(ChatMessage::system(¬e));
6610 self.transcript.log_system(¬e);
6611 let _ = tx
6612 .send(InferenceEvent::MutedToken(format!("\n{}", note)))
6613 .await;
6614 }
6615
6616 self.emit_done_events(&tx).await;
6617 break;
6618 } else {
6619 let detail = "Model returned an empty response.";
6620 let class = classify_runtime_failure(detail);
6621 if should_retry_runtime_failure(class) {
6622 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6623 if let RecoveryDecision::Attempt(plan) =
6624 attempt_recovery(scenario, &mut self.recovery_context)
6625 {
6626 self.transcript.log_system(
6627 "Automatic provider recovery triggered: model returned an empty response.",
6628 );
6629 self.emit_recovery_recipe_summary(
6630 &tx,
6631 plan.recipe.scenario.label(),
6632 compact_recovery_plan_summary(&plan),
6633 )
6634 .await;
6635 let _ = tx
6636 .send(InferenceEvent::ProviderStatus {
6637 state: ProviderRuntimeState::Recovering,
6638 summary: compact_runtime_recovery_summary(class).into(),
6639 })
6640 .await;
6641 self.emit_operator_checkpoint(
6642 &tx,
6643 OperatorCheckpointState::RecoveringProvider,
6644 compact_runtime_recovery_summary(class),
6645 )
6646 .await;
6647 continue;
6648 }
6649 }
6650 }
6651
6652 if explicit_search_request
6653 && matches!(
6654 class,
6655 RuntimeFailureClass::ProviderDegraded
6656 | RuntimeFailureClass::EmptyModelResponse
6657 )
6658 {
6659 if let Some(results) = grounded_research_results.as_deref() {
6660 let response = build_research_provider_fallback(results);
6661 self.history.push(ChatMessage::assistant_text(&response));
6662 self.transcript.log_agent(&response);
6663 for chunk in chunk_text(&response, 8) {
6664 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6665 }
6666 let _ = tx.send(InferenceEvent::Done).await;
6667 return Ok(());
6668 }
6669 }
6670
6671 if implement_current_plan
6672 && mutation_occurred
6673 && matches!(class, RuntimeFailureClass::EmptyModelResponse)
6674 {
6675 if let Some(summary) = maybe_deterministic_sovereign_closeout(
6676 self.session_memory.current_plan.as_ref(),
6677 mutation_occurred,
6678 ) {
6679 self.history.push(ChatMessage::assistant_text(&summary));
6680 self.transcript.log_agent(&summary);
6681 for chunk in chunk_text(&summary, 8) {
6682 let _ = tx.send(InferenceEvent::Token(chunk)).await;
6683 }
6684 let _ = tx.send(InferenceEvent::Done).await;
6685 return Ok(());
6686 }
6687 }
6688
6689 self.emit_runtime_failure(&tx, class, detail).await;
6690 break;
6691 }
6692 }
6693
6694 let task_progress_after = if implement_current_plan {
6695 read_task_checklist_progress()
6696 } else {
6697 None
6698 };
6699
6700 if implement_current_plan
6701 && !visible_closeout_emitted
6702 && should_continue_plan_execution(
6703 current_plan_pass,
6704 task_progress_before,
6705 task_progress_after,
6706 &turn_mutated_paths,
6707 )
6708 {
6709 if let Some(progress) = task_progress_after {
6710 let _ = tx
6711 .send(InferenceEvent::Thought(format!(
6712 "Checklist still has {} unchecked item(s). Continuing autonomous implementation pass {}.",
6713 progress.remaining,
6714 current_plan_pass + 1
6715 )))
6716 .await;
6717 let synthetic_turn = UserTurn {
6718 text: build_continue_plan_execution_prompt(progress),
6719 attached_document: None,
6720 attached_image: None,
6721 };
6722 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6723 }
6724 }
6725
6726 if implement_current_plan
6727 && !visible_closeout_emitted
6728 && turn_mutated_paths.is_empty()
6729 && current_plan_pass == 1
6730 {
6731 if let Some(progress) = task_progress_after.filter(|progress| progress.has_open_items())
6732 {
6733 let target_files = self
6734 .session_memory
6735 .current_plan
6736 .as_ref()
6737 .map(|plan| plan.target_files.clone())
6738 .unwrap_or_default();
6739 let _ = tx
6740 .send(InferenceEvent::Thought(
6741 "No target files were mutated during the first current-plan pass. Forcing one grounded implementation retry before allowing summary mode."
6742 .to_string(),
6743 ))
6744 .await;
6745 let synthetic_turn = UserTurn {
6746 text: build_force_plan_mutation_prompt(progress, &target_files),
6747 attached_document: None,
6748 attached_image: None,
6749 };
6750 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6751 }
6752 }
6753
6754 if implement_current_plan
6755 && !visible_closeout_emitted
6756 && !turn_mutated_paths.is_empty()
6757 && current_plan_pass <= 2
6758 {
6759 if let (Some(before), Some(after)) = (task_progress_before, task_progress_after) {
6760 if after.has_open_items()
6761 && after.remaining == before.remaining
6762 && after.completed == before.completed
6763 {
6764 let target_files = self
6765 .session_memory
6766 .current_plan
6767 .as_ref()
6768 .map(|plan| plan.target_files.clone())
6769 .unwrap_or_default();
6770 let _ = tx
6771 .send(InferenceEvent::Thought(
6772 "Implementation mutated target files, but the task ledger did not advance. Forcing one closeout pass to update `.hematite/TASK.md` before summary mode."
6773 .to_string(),
6774 ))
6775 .await;
6776 let synthetic_turn = UserTurn {
6777 text: build_task_ledger_closeout_prompt(after, &target_files),
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
6786 if implement_current_plan && !visible_closeout_emitted {
6787 let _ = tx.send(InferenceEvent::Thought("Implementation passthrough complete. Requesting final engineering summary (NLG-only mode)...".to_string())).await;
6789
6790 let outstanding_note = task_progress_after
6791 .filter(|progress| progress.has_open_items())
6792 .map(|progress| {
6793 format!(
6794 " `.hematite/TASK.md` still has {} unchecked item(s); explain the concrete blocker or remaining non-optional work.",
6795 progress.remaining
6796 )
6797 })
6798 .unwrap_or_default();
6799 let synthetic_turn = UserTurn {
6800 text: format!(
6801 "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.{}",
6802 outstanding_note
6803 ),
6804 attached_document: None,
6805 attached_image: None,
6806 };
6807 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), true)).await;
6810 }
6811
6812 if plan_drafted_this_turn
6813 && matches!(
6814 self.workflow_mode,
6815 WorkflowMode::Auto | WorkflowMode::Architect
6816 )
6817 {
6818 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
6819 let _ = tx
6820 .send(InferenceEvent::ApprovalRequired {
6821 id: "plan_approval".to_string(),
6822 name: "plan_authorization".to_string(),
6823 display: "A comprehensive scaffolding blueprint has been drafted to .hematite/PLAN.md. Autonomously execute implementation now?".to_string(),
6824 diff: None,
6825 mutation_label: Some("SYSTEM PLAN AUTHORIZATION".to_string()),
6826 responder: appr_tx,
6827 })
6828 .await;
6829
6830 if let Ok(true) = appr_rx.await {
6831 self.history.clear();
6835 self.running_summary = None;
6836 self.set_workflow_mode(WorkflowMode::Code);
6837
6838 let _ = tx.send(InferenceEvent::ChainImplementPlan).await;
6839
6840 let next_input = implement_current_plan_prompt().to_string();
6841 let synthetic_turn = UserTurn {
6842 text: next_input,
6843 attached_document: None,
6844 attached_image: None,
6845 };
6846 return Box::pin(self.run_turn(&synthetic_turn, tx.clone(), false)).await;
6847 }
6848 }
6849
6850 self.trim_history(80);
6851 self.refresh_session_memory();
6852 self.last_goal = Some(user_input.chars().take(300).collect());
6854 self.turn_count = self.turn_count.saturating_add(1);
6855 self.emit_compaction_pressure(&tx).await;
6856
6857 {
6859 let (input_end, output_end) = {
6860 let econ = self
6861 .engine
6862 .economics
6863 .lock()
6864 .unwrap_or_else(|p| p.into_inner());
6865 (econ.input_tokens, econ.output_tokens)
6866 };
6867 let context_pct = {
6868 let ctx_len = self.engine.current_context_length();
6869 let total = input_end.saturating_sub(budget_input_start)
6870 + output_end.saturating_sub(budget_output_start);
6871 (total * 100).checked_div(ctx_len).unwrap_or(0).min(100) as u8
6872 };
6873 let mut tool_costs: Vec<crate::agent::economics::ToolCost> =
6875 Vec::with_capacity(budget_tool_costs.len());
6876 for tc in &budget_tool_costs {
6877 if let Some(existing) = tool_costs.iter_mut().find(|e| e.name == tc.name) {
6878 existing.tokens += tc.tokens;
6879 } else {
6880 tool_costs.push(crate::agent::economics::ToolCost {
6881 name: tc.name.clone(),
6882 tokens: tc.tokens,
6883 });
6884 }
6885 }
6886 let budget = crate::agent::economics::TurnBudget {
6887 input_tokens: input_end.saturating_sub(budget_input_start),
6888 output_tokens: output_end.saturating_sub(budget_output_start),
6889 history_est: budget_history_est,
6890 tool_costs,
6891 context_pct,
6892 };
6893 let _ = tx.send(InferenceEvent::Thought(budget.render())).await;
6894 self.last_turn_budget = Some(budget);
6895 }
6896
6897 if !implement_current_plan {
6899 let tracker = self.diff_tracker.lock().await;
6900 if let Ok(diff) = tracker.generate_diff() {
6901 if !diff.is_empty() {
6902 let _ = tx
6903 .send(InferenceEvent::Thought(format!(
6904 "AUTHORITATIVE TURN SUMMARY:\n\n```diff\n{}\n```",
6905 diff
6906 )))
6907 .await;
6908
6909 self.transcript
6911 .log_system(&format!("Turn Diff Summary:\n{}", diff));
6912 }
6913 }
6914 }
6915
6916 Ok(())
6917 }
6918
6919 async fn emit_runtime_failure(
6920 &mut self,
6921 tx: &mpsc::Sender<InferenceEvent>,
6922 class: RuntimeFailureClass,
6923 detail: &str,
6924 ) {
6925 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
6926 let decision = preview_recovery_decision(scenario, &self.recovery_context);
6927 self.emit_recovery_recipe_summary(
6928 tx,
6929 scenario.label(),
6930 compact_recovery_decision_summary(&decision),
6931 )
6932 .await;
6933 let needs_refresh = match &decision {
6934 RecoveryDecision::Attempt(plan) => plan
6935 .recipe
6936 .steps
6937 .contains(&RecoveryStep::RefreshRuntimeProfile),
6938 RecoveryDecision::Escalate { recipe, .. } => {
6939 recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
6940 }
6941 };
6942 if needs_refresh {
6943 if let Some((model_id, context_length, changed)) = self
6944 .refresh_runtime_profile_and_report(tx, "context_window_failure")
6945 .await
6946 {
6947 let note = if changed {
6948 format!(
6949 "Runtime refresh after context-window failure: model {} | CTX {}",
6950 model_id, context_length
6951 )
6952 } else {
6953 format!(
6954 "Runtime refresh after context-window failure confirms model {} | CTX {}",
6955 model_id, context_length
6956 )
6957 };
6958 let _ = tx.send(InferenceEvent::Thought(note)).await;
6959 }
6960 }
6961 }
6962 if let Some(state) = provider_state_for_runtime_failure(class) {
6963 let _ = tx
6964 .send(InferenceEvent::ProviderStatus {
6965 state,
6966 summary: compact_runtime_failure_summary(class).into(),
6967 })
6968 .await;
6969 }
6970 if let Some(state) = checkpoint_state_for_runtime_failure(class) {
6971 self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
6972 .await;
6973 }
6974 let formatted = format_runtime_failure(class, detail);
6975 self.history.push(ChatMessage::system(&format!(
6976 "# RUNTIME FAILURE\n{}",
6977 formatted
6978 )));
6979 self.transcript.log_system(&formatted);
6980 let _ = tx.send(InferenceEvent::Error(formatted)).await;
6981 let _ = tx.send(InferenceEvent::Done).await;
6982 }
6983
6984 async fn auto_verify_workspace(
6987 &self,
6988 mutated_paths: &std::collections::BTreeSet<String>,
6989 ) -> AutoVerificationOutcome {
6990 let root = crate::tools::file_ops::workspace_root();
6991 let profile = crate::agent::workspace_profile::load_workspace_profile(&root)
6992 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(&root));
6993
6994 let mut sections = Vec::with_capacity(4);
6995 let mut overall_ok = true;
6996 let contract = profile.runtime_contract.as_ref();
6997 let verification_workflows: Vec<String> = match contract {
6998 Some(contract) if !contract.verification_workflows.is_empty() => {
6999 contract.verification_workflows.clone()
7000 }
7001 _ if profile.build_hint.is_some() || profile.verify_profile.is_some() => {
7002 vec!["build".to_string()]
7003 }
7004 _ => Vec::new(),
7005 };
7006
7007 for workflow in verification_workflows {
7008 if !should_run_contract_verification_workflow(contract, &workflow, mutated_paths) {
7009 continue;
7010 }
7011 let outcome = self.auto_run_verification_workflow(&workflow).await;
7012 overall_ok &= outcome.ok;
7013 sections.push(outcome.summary);
7014 }
7015
7016 if sections.is_empty() {
7017 sections.push(
7018 "[verify]\nVERIFICATION SKIPPED: Workspace profile does not define an automatic verification workflow for this stack."
7019 .to_string(),
7020 );
7021 }
7022
7023 let header = if overall_ok {
7024 "WORKSPACE VERIFICATION SUCCESS: Automatic validation passed."
7025 } else {
7026 "WORKSPACE VERIFICATION FAILURE: Automatic validation found problems."
7027 };
7028
7029 AutoVerificationOutcome {
7030 ok: overall_ok,
7031 summary: format!("{}\n\n{}", header, sections.join("\n\n")),
7032 }
7033 }
7034
7035 async fn auto_run_verification_workflow(&self, workflow: &str) -> AutoVerificationOutcome {
7036 match workflow {
7037 "build" | "test" | "lint" | "fix" => {
7038 match crate::tools::verify_build::execute(
7039 &serde_json::json!({ "action": workflow }),
7040 )
7041 .await
7042 {
7043 Ok(out) => AutoVerificationOutcome {
7044 ok: true,
7045 summary: format!(
7046 "[{}]\n{} SUCCESS: Automatic {} verification passed.\n\n{}",
7047 workflow,
7048 workflow.to_ascii_uppercase(),
7049 workflow,
7050 cap_output(&out, 2000)
7051 ),
7052 },
7053 Err(e) => AutoVerificationOutcome {
7054 ok: false,
7055 summary: format!(
7056 "[{}]\n{} FAILURE: Automatic {} verification failed.\n\n{}",
7057 workflow,
7058 workflow.to_ascii_uppercase(),
7059 workflow,
7060 cap_output(&e, 2000)
7061 ),
7062 },
7063 }
7064 }
7065 other => {
7066 let args = serde_json::json!({ "workflow": other });
7068 match crate::tools::workspace_workflow::run_workspace_workflow(&args).await {
7069 Ok(out) => {
7070 let ok = !out.contains("Result: FAIL") && !out.contains("Error:");
7073 AutoVerificationOutcome {
7074 ok,
7075 summary: format!("[{}]\n{}", other, out.trim()),
7076 }
7077 }
7078 Err(e) => {
7079 let needs_boot = e.contains("No tracked website server labeled")
7083 || e.contains("HTTP probe failed")
7084 || e.contains("Connection refused")
7085 || e.contains("error trying to connect");
7086
7087 if other == "website_validate" && needs_boot {
7088 let start_args = serde_json::json!({ "workflow": "website_start" });
7089 if crate::tools::workspace_workflow::run_workspace_workflow(&start_args)
7090 .await
7091 .is_ok()
7092 {
7093 if let Ok(retry_out) =
7094 crate::tools::workspace_workflow::run_workspace_workflow(&args)
7095 .await
7096 {
7097 let ok = !retry_out.contains("Result: FAIL")
7098 && !retry_out.contains("Error:");
7099 return AutoVerificationOutcome {
7100 ok,
7101 summary: format!(
7102 "[{}]\n(Auto-booted) {}",
7103 other,
7104 retry_out.trim()
7105 ),
7106 };
7107 }
7108 }
7109 }
7110
7111 AutoVerificationOutcome {
7112 ok: false,
7113 summary: format!("[{}]\nVERIFICATION FAILURE: {}", other, e),
7114 }
7115 }
7116 }
7117 }
7118 }
7119 }
7120
7121 async fn compact_history_if_needed(
7125 &mut self,
7126 tx: &mpsc::Sender<InferenceEvent>,
7127 anchor_index: Option<usize>,
7128 ) -> Result<bool, String> {
7129 let vram_ratio = self.gpu_state.ratio();
7130 let context_length = self.engine.current_context_length();
7131 let config = CompactionConfig::adaptive(context_length, vram_ratio);
7132
7133 if !compaction::should_compact(&self.history, context_length, vram_ratio) {
7134 return Ok(false);
7135 }
7136
7137 let _ = tx
7138 .send(InferenceEvent::Thought(format!(
7139 "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
7140 context_length / 1000,
7141 vram_ratio * 100.0,
7142 config.max_estimated_tokens / 1000,
7143 )))
7144 .await;
7145
7146 let result = compaction::compact_history(
7147 &self.history,
7148 self.running_summary.as_deref(),
7149 config,
7150 anchor_index,
7151 );
7152
7153 let removed_message_count = self.history.len().saturating_sub(result.messages.len());
7154 self.history = result.messages;
7155 self.running_summary = result.summary;
7156
7157 let last_checkpoint = self.session_memory.last_checkpoint.take();
7159 let last_blocker = self.session_memory.last_blocker.take();
7160 let last_recovery = self.session_memory.last_recovery.take();
7161 let last_verification = self.session_memory.last_verification.take();
7162 let last_compaction = self.session_memory.last_compaction.take();
7163 self.session_memory = compaction::extract_memory(&self.history);
7164 self.session_memory.last_checkpoint = last_checkpoint;
7165 self.session_memory.last_blocker = last_blocker;
7166 self.session_memory.last_recovery = last_recovery;
7167 self.session_memory.last_verification = last_verification;
7168 self.session_memory.last_compaction = last_compaction;
7169 self.session_memory.record_compaction(
7170 removed_message_count,
7171 format!(
7172 "Compacted history around active task '{}' and preserved {} working-set file(s).",
7173 self.session_memory.current_task,
7174 self.session_memory.working_set.len()
7175 ),
7176 );
7177 self.emit_compaction_pressure(tx).await;
7178
7179 let first_non_sys = self
7182 .history
7183 .iter()
7184 .position(|m| m.role != "system")
7185 .unwrap_or(self.history.len());
7186 if first_non_sys < self.history.len() {
7187 if let Some(user_offset) = self.history[first_non_sys..]
7188 .iter()
7189 .position(|m| m.role == "user")
7190 {
7191 if user_offset > 0 {
7192 self.history
7193 .drain(first_non_sys..first_non_sys + user_offset);
7194 }
7195 }
7196 }
7197
7198 let _ = tx
7199 .send(InferenceEvent::Thought(format!(
7200 "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
7201 self.session_memory.current_task,
7202 self.session_memory.working_set.len()
7203 )))
7204 .await;
7205 let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
7206 self.emit_recovery_recipe_summary(
7207 tx,
7208 recipe.recipe.scenario.label(),
7209 compact_recovery_plan_summary(&recipe),
7210 )
7211 .await;
7212 self.emit_operator_checkpoint(
7213 tx,
7214 OperatorCheckpointState::HistoryCompacted,
7215 format!(
7216 "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
7217 self.session_memory.current_task,
7218 self.session_memory.working_set.len()
7219 ),
7220 )
7221 .await;
7222
7223 Ok(true)
7224 }
7225
7226 fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
7230 if query.split_whitespace().count() < 3 {
7232 return None;
7233 }
7234
7235 let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
7236 if results.is_empty() {
7237 return None;
7238 }
7239
7240 let semantic_active = self.vein.has_any_embeddings();
7241 let header = if semantic_active {
7242 "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
7243 Use this to answer without needing extra read_file calls where possible.\n\n"
7244 } else {
7245 "# Relevant context from The Vein (BM25 keyword retrieval)\n\
7246 Use this to answer without needing extra read_file calls where possible.\n\n"
7247 };
7248
7249 let mut ctx = String::from(header);
7250 let mut paths: Vec<String> = Vec::with_capacity(results.len());
7251
7252 let mut total = 0usize;
7253 const MAX_CTX_CHARS: usize = 1_500;
7254
7255 for r in results {
7256 if total >= MAX_CTX_CHARS {
7257 break;
7258 }
7259 let snippet = if r.content.len() > 500 {
7260 format!("{}...", safe_head(&r.content, 500))
7261 } else {
7262 r.content.clone()
7263 };
7264 let _ = write!(ctx, "--- {} ---\n{}\n\n", r.path, snippet);
7265 total += snippet.len() + r.path.len() + 10;
7266 if !paths.contains(&r.path) {
7267 paths.push(r.path);
7268 }
7269 }
7270
7271 Some((ctx, paths))
7272 }
7273
7274 fn context_window_slice(&self) -> Vec<ChatMessage> {
7277 let mut result = Vec::with_capacity(self.history.len().saturating_sub(1));
7278
7279 if self.history.len() > 1 {
7281 for m in &self.history[1..] {
7282 if m.role == "system" {
7283 continue;
7284 }
7285
7286 let mut sanitized = m.clone();
7287 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7289 sanitized.content = MessageContent::Text(" ".into());
7290 }
7291 result.push(sanitized);
7292 }
7293 }
7294
7295 if !result.is_empty() && result[0].role != "user" {
7298 result.insert(0, ChatMessage::user("Continuing previous context..."));
7299 }
7300
7301 result
7302 }
7303
7304 fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
7305 let mut result = Vec::with_capacity(self.history.len().saturating_sub(start_idx.max(1)));
7306
7307 if self.history.len() > 1 {
7308 let start = start_idx.max(1).min(self.history.len());
7309 for m in &self.history[start..] {
7310 if m.role == "system" {
7311 continue;
7312 }
7313
7314 let mut sanitized = m.clone();
7315 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
7316 sanitized.content = MessageContent::Text(" ".into());
7317 }
7318 result.push(sanitized);
7319 }
7320 }
7321
7322 if !result.is_empty() && result[0].role != "user" {
7323 result.insert(0, ChatMessage::user("Continuing current plan execution..."));
7324 }
7325
7326 result
7327 }
7328
7329 fn trim_history(&mut self, max_messages: usize) {
7331 if self.history.len() <= max_messages {
7332 return;
7333 }
7334 let excess = self.history.len() - max_messages;
7336 self.history.drain(1..=excess);
7337 }
7338
7339 #[allow(dead_code)]
7341 async fn repair_tool_args(
7342 &self,
7343 tool_name: &str,
7344 bad_json: &str,
7345 tx: &mpsc::Sender<InferenceEvent>,
7346 ) -> Result<Value, String> {
7347 let _ = tx
7348 .send(InferenceEvent::Thought(format!(
7349 "Attempting to repair malformed JSON for '{}'...",
7350 tool_name
7351 )))
7352 .await;
7353
7354 let prompt = format!(
7355 "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.",
7356 tool_name, bad_json
7357 );
7358
7359 let messages = vec![
7360 ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
7361 ChatMessage::user(&prompt),
7362 ];
7363
7364 let (text, _, _, _) = self
7366 .engine
7367 .call_with_tools(&messages, &[], self.fast_model.as_deref())
7368 .await
7369 .map_err(|e| e.to_string())?;
7370
7371 let cleaned = text
7372 .unwrap_or_default()
7373 .trim()
7374 .trim_start_matches("```json")
7375 .trim_start_matches("```")
7376 .trim_end_matches("```")
7377 .trim()
7378 .to_string();
7379
7380 serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
7381 }
7382
7383 async fn run_critic_check(
7385 &self,
7386 path: &str,
7387 content: &str,
7388 tx: &mpsc::Sender<InferenceEvent>,
7389 ) -> Option<String> {
7390 let ext = std::path::Path::new(path)
7392 .extension()
7393 .and_then(|e| e.to_str())
7394 .unwrap_or("");
7395 const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
7396 if !CRITIC_EXTS.contains(&ext) {
7397 return None;
7398 }
7399
7400 let _ = tx
7401 .send(InferenceEvent::Thought(format!(
7402 "CRITIC: Reviewing changes to '{}'...",
7403 path
7404 )))
7405 .await;
7406
7407 let truncated = cap_output(content, 4000);
7408
7409 const WEB_EXTS_CRITIC: &[&str] = &[
7410 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
7411 ];
7412 let is_web_file = WEB_EXTS_CRITIC.contains(&ext);
7413
7414 let prompt = if is_web_file {
7415 format!(
7416 "You are a senior web developer doing a quality review of '{}'. \
7417 Identify ONLY real problems — missing, broken, or incomplete things that would \
7418 make this file not work or look bad in production. Check:\n\
7419 - HTML: missing DOCTYPE/charset/title/viewport meta, broken links, missing aria, unsemantic structure\n\
7420 - CSS: hardcoded px instead of responsive units, missing mobile media queries, class names used in HTML but not defined here\n\
7421 - JS/TS: missing error handling, undefined variables, console.log left in, DOM elements referenced that may not exist\n\
7422 - All: placeholder text/colors/lorem-ipsum left in, TODO comments, empty sections\n\
7423 Be extremely concise. List issues as short bullets. If everything is production-ready, output 'PASS'.\n\n\
7424 ```{}\n{}\n```",
7425 path, ext, truncated
7426 )
7427 } else {
7428 format!(
7429 "You are a Senior Security and Code Quality auditor. Review this file content for '{}' \
7430 and identify any critical logic errors, security vulnerabilities, or missing error handling. \
7431 Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
7432 path, ext, truncated
7433 )
7434 };
7435
7436 let messages = vec![
7437 ChatMessage::system("You are a technical critic. Identify ONLY real issues that need fixing. Output 'PASS' if none found."),
7438 ChatMessage::user(&prompt)
7439 ];
7440
7441 let (text, _, _, _) = self
7442 .engine
7443 .call_with_tools(&messages, &[], self.fast_model.as_deref())
7444 .await
7445 .ok()?;
7446
7447 let critique = text?.trim().to_string();
7448 if critique.to_uppercase().contains("PASS") || critique.is_empty() {
7449 None
7450 } else {
7451 Some(critique)
7452 }
7453 }
7454}
7455
7456pub async fn dispatch_tool(
7459 name: &str,
7460 args: &Value,
7461 config: &crate::agent::config::HematiteConfig,
7462 budget_tokens: usize,
7463) -> Result<String, String> {
7464 dispatch_builtin_tool(name, args, config, budget_tokens).await
7465}
7466
7467fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
7468 let trimmed = text.trim();
7469 let stripped = trimmed
7470 .strip_prefix("/think")
7471 .or_else(|| trimmed.strip_prefix("/no_think"))
7472 .map(str::trim)
7473 .unwrap_or(trimmed)
7474 .trim_start_matches('\n')
7475 .trim();
7476 (!stripped.is_empty()).then(|| stripped.to_string())
7477}
7478
7479fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
7480 if tool_name != "inspect_host" {
7481 return;
7482 }
7483
7484 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7485 return;
7486 };
7487 if topic != "fix_plan" {
7488 return;
7489 }
7490
7491 let issue_missing = args
7492 .get("issue")
7493 .and_then(|v| v.as_str())
7494 .map(str::trim)
7495 .is_none_or(|value| value.is_empty());
7496 if !issue_missing {
7497 return;
7498 }
7499
7500 let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
7501 return;
7502 };
7503
7504 let Value::Object(map) = args else {
7505 return;
7506 };
7507 map.insert(
7508 "issue".to_string(),
7509 Value::String(fallback_issue.to_string()),
7510 );
7511}
7512
7513fn fill_missing_dns_lookup_name(
7514 tool_name: &str,
7515 args: &mut Value,
7516 latest_user_prompt: Option<&str>,
7517) {
7518 if tool_name != "inspect_host" {
7519 return;
7520 }
7521
7522 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7523 return;
7524 };
7525 if topic != "dns_lookup" {
7526 return;
7527 }
7528
7529 let name_missing = args
7530 .get("name")
7531 .and_then(|v| v.as_str())
7532 .map(str::trim)
7533 .is_none_or(|value| value.is_empty());
7534 if !name_missing {
7535 return;
7536 }
7537
7538 let Some(prompt) = latest_user_prompt else {
7539 return;
7540 };
7541 let Some(name) = extract_dns_lookup_target_from_text(prompt) else {
7542 return;
7543 };
7544
7545 let Value::Object(map) = args else {
7546 return;
7547 };
7548 map.insert("name".to_string(), Value::String(name));
7549}
7550
7551fn fill_missing_dns_lookup_type(
7552 tool_name: &str,
7553 args: &mut Value,
7554 latest_user_prompt: Option<&str>,
7555) {
7556 if tool_name != "inspect_host" {
7557 return;
7558 }
7559
7560 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7561 return;
7562 };
7563 if topic != "dns_lookup" {
7564 return;
7565 }
7566
7567 let type_missing = args
7568 .get("type")
7569 .and_then(|v| v.as_str())
7570 .map(str::trim)
7571 .is_none_or(|value| value.is_empty());
7572 if !type_missing {
7573 return;
7574 }
7575
7576 let record_type = latest_user_prompt
7577 .and_then(extract_dns_record_type_from_text)
7578 .unwrap_or("A");
7579
7580 let Value::Object(map) = args else {
7581 return;
7582 };
7583 map.insert("type".to_string(), Value::String(record_type.to_string()));
7584}
7585
7586fn fill_missing_event_query_args(
7587 tool_name: &str,
7588 args: &mut Value,
7589 latest_user_prompt: Option<&str>,
7590) {
7591 if tool_name != "inspect_host" {
7592 return;
7593 }
7594
7595 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
7596 return;
7597 };
7598 if topic != "event_query" {
7599 return;
7600 }
7601
7602 let Some(prompt) = latest_user_prompt else {
7603 return;
7604 };
7605
7606 let Value::Object(map) = args else {
7607 return;
7608 };
7609
7610 let event_id_missing = map.get("event_id").and_then(|v| v.as_u64()).is_none();
7611 if event_id_missing {
7612 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7613 map.insert(
7614 "event_id".to_string(),
7615 Value::Number(serde_json::Number::from(event_id)),
7616 );
7617 }
7618 }
7619
7620 let log_missing = map
7621 .get("log")
7622 .and_then(|v| v.as_str())
7623 .map(str::trim)
7624 .is_none_or(|value| value.is_empty());
7625 if log_missing {
7626 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7627 map.insert("log".to_string(), Value::String(log_name.to_string()));
7628 }
7629 }
7630
7631 let level_missing = map
7632 .get("level")
7633 .and_then(|v| v.as_str())
7634 .map(str::trim)
7635 .is_none_or(|value| value.is_empty());
7636 if level_missing {
7637 if let Some(level) = extract_event_query_level_from_text(prompt) {
7638 map.insert("level".to_string(), Value::String(level.to_string()));
7639 }
7640 }
7641
7642 let hours_missing = map.get("hours").and_then(|v| v.as_u64()).is_none();
7643 if hours_missing {
7644 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7645 map.insert(
7646 "hours".to_string(),
7647 Value::Number(serde_json::Number::from(hours)),
7648 );
7649 }
7650 }
7651}
7652
7653fn should_rewrite_shell_to_fix_plan(
7654 tool_name: &str,
7655 args: &Value,
7656 latest_user_prompt: Option<&str>,
7657) -> bool {
7658 if tool_name != "shell" {
7659 return false;
7660 }
7661 let Some(prompt) = latest_user_prompt else {
7662 return false;
7663 };
7664 if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
7665 return false;
7666 }
7667 let command = args
7668 .get("command")
7669 .and_then(|value| value.as_str())
7670 .unwrap_or("");
7671 shell_looks_like_structured_host_inspection(command)
7672}
7673
7674fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
7675 use std::sync::OnceLock;
7676 static RE_VERSION: OnceLock<regex::Regex> = OnceLock::new();
7677 static RE_BUMP: OnceLock<regex::Regex> = OnceLock::new();
7678 let re = match flag {
7679 "-Version" => RE_VERSION.get_or_init(|| {
7680 regex::Regex::new(r#"(?i)-Version\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7681 }),
7682 "-Bump" => RE_BUMP.get_or_init(|| {
7683 regex::Regex::new(r#"(?i)-Bump\s+['"]?([^'" \r\n]+)['"]?"#).expect("valid")
7684 }),
7685 other => {
7686 let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(other));
7687 return regex::Regex::new(&pattern).ok().and_then(|re| {
7688 re.captures(command)
7689 .and_then(|c| c.get(1))
7690 .map(|m| m.as_str().to_string())
7691 });
7692 }
7693 };
7694 re.captures(command)?.get(1).map(|m| m.as_str().to_string())
7695}
7696
7697fn clean_shell_dns_token(token: &str) -> String {
7698 token
7699 .trim_matches(|c: char| {
7700 c.is_whitespace()
7701 || matches!(
7702 c,
7703 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ';' | ',' | '`'
7704 )
7705 })
7706 .trim_end_matches([':', '.'])
7707 .to_string()
7708}
7709
7710fn looks_like_dns_target(token: &str) -> bool {
7711 let cleaned = clean_shell_dns_token(token);
7712 if cleaned.is_empty() {
7713 return false;
7714 }
7715
7716 let lower = cleaned.to_ascii_lowercase();
7717 if matches!(
7718 lower.as_str(),
7719 "a" | "aaaa"
7720 | "mx"
7721 | "srv"
7722 | "txt"
7723 | "cname"
7724 | "ptr"
7725 | "soa"
7726 | "any"
7727 | "resolve-dnsname"
7728 | "nslookup"
7729 | "host"
7730 | "dig"
7731 | "powershell"
7732 | "-command"
7733 | "foreach-object"
7734 | "select-object"
7735 | "address"
7736 | "ipaddress"
7737 | "name"
7738 | "type"
7739 ) {
7740 return false;
7741 }
7742
7743 if lower == "localhost" || cleaned.parse::<std::net::IpAddr>().is_ok() {
7744 return true;
7745 }
7746
7747 cleaned.contains('.')
7748 && cleaned
7749 .chars()
7750 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '*'))
7751}
7752
7753fn dns_quoted_re() -> &'static regex::Regex {
7754 use std::sync::OnceLock;
7755 static RE: OnceLock<regex::Regex> = OnceLock::new();
7756 RE.get_or_init(|| regex::Regex::new(r#"['"]([^'"]+)['"]"#).expect("valid"))
7757}
7758
7759fn extract_dns_lookup_target_from_shell(command: &str) -> Option<String> {
7760 use std::sync::OnceLock;
7761 static RE1: OnceLock<regex::Regex> = OnceLock::new();
7762 static RE2: OnceLock<regex::Regex> = OnceLock::new();
7763 static RE3: OnceLock<regex::Regex> = OnceLock::new();
7764 let re1 = RE1.get_or_init(|| {
7765 regex::Regex::new(r#"(?i)-name\s+['"]?([^'"\s;()]+)['"]?"#).expect("valid")
7766 });
7767 let re2 = RE2.get_or_init(|| {
7768 regex::Regex::new(r#"(?i)(?:gethostaddresses|gethostentry)\s*\(\s*['"]([^'"]+)['"]\s*\)"#)
7769 .expect("valid")
7770 });
7771 let re3 = RE3.get_or_init(|| {
7772 regex::Regex::new(
7773 r#"(?i)\b(?:resolve-dnsname|nslookup|host|dig)\s+['"]?([^'"\s;()]+)['"]?"#,
7774 )
7775 .expect("valid")
7776 });
7777 for re in [re1, re2, re3] {
7778 if let Some(value) = re
7779 .captures(command)
7780 .and_then(|captures| captures.get(1).map(|m| clean_shell_dns_token(m.as_str())))
7781 .filter(|value| looks_like_dns_target(value))
7782 {
7783 return Some(value);
7784 }
7785 }
7786
7787 let quoted = dns_quoted_re();
7788 for captures in quoted.captures_iter(command) {
7789 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7790 if looks_like_dns_target(&candidate) {
7791 return Some(candidate);
7792 }
7793 }
7794
7795 command
7796 .split_whitespace()
7797 .map(clean_shell_dns_token)
7798 .find(|token| looks_like_dns_target(token))
7799}
7800
7801fn extract_dns_lookup_target_from_text(text: &str) -> Option<String> {
7802 let quoted = dns_quoted_re();
7803 for captures in quoted.captures_iter(text) {
7804 let candidate = clean_shell_dns_token(captures.get(1)?.as_str());
7805 if looks_like_dns_target(&candidate) {
7806 return Some(candidate);
7807 }
7808 }
7809
7810 text.split_whitespace()
7811 .map(clean_shell_dns_token)
7812 .find(|token| looks_like_dns_target(token))
7813}
7814
7815fn extract_dns_record_type_from_text(text: &str) -> Option<&'static str> {
7816 let lower = text.to_ascii_lowercase();
7817 if lower.contains("aaaa record") || lower.contains("ipv6 address") {
7818 Some("AAAA")
7819 } else if lower.contains("mx record") {
7820 Some("MX")
7821 } else if lower.contains("srv record") {
7822 Some("SRV")
7823 } else if lower.contains("txt record") {
7824 Some("TXT")
7825 } else if lower.contains("cname record") {
7826 Some("CNAME")
7827 } else if lower.contains("soa record") {
7828 Some("SOA")
7829 } else if lower.contains("ptr record") {
7830 Some("PTR")
7831 } else if lower.contains("a record")
7832 || (lower.contains("ip address") && lower.contains(" of "))
7833 || (lower.contains("what") && lower.contains("ip") && lower.contains("for"))
7834 {
7835 Some("A")
7836 } else {
7837 None
7838 }
7839}
7840
7841fn extract_event_query_event_id_from_text(text: &str) -> Option<u32> {
7842 use std::sync::OnceLock;
7843 static RE: OnceLock<regex::Regex> = OnceLock::new();
7844 let re = RE.get_or_init(|| {
7845 regex::Regex::new(r"(?i)\bevent(?:\s*_?\s*id)?\s*[:#]?\s*(\d{2,5})\b").expect("valid")
7846 });
7847 re.captures(text)
7848 .and_then(|captures| captures.get(1))
7849 .and_then(|m| m.as_str().parse::<u32>().ok())
7850}
7851
7852fn extract_event_query_log_from_text(text: &str) -> Option<&'static str> {
7853 let lower = text.to_ascii_lowercase();
7854 if lower.contains("security log") {
7855 Some("Security")
7856 } else if lower.contains("application log") {
7857 Some("Application")
7858 } else if lower.contains("system log") || lower.contains("system errors") {
7859 Some("System")
7860 } else if lower.contains("setup log") {
7861 Some("Setup")
7862 } else {
7863 None
7864 }
7865}
7866
7867fn extract_event_query_level_from_text(text: &str) -> Option<&'static str> {
7868 let lower = text.to_ascii_lowercase();
7869 if lower.contains("critical") {
7870 Some("Critical")
7871 } else if lower.contains("error") || lower.contains("errors") {
7872 Some("Error")
7873 } else if lower.contains("warning") || lower.contains("warnings") || lower.contains("warn") {
7874 Some("Warning")
7875 } else if lower.contains("information")
7876 || lower.contains("informational")
7877 || lower.contains("info")
7878 {
7879 Some("Information")
7880 } else {
7881 None
7882 }
7883}
7884
7885fn extract_event_query_hours_from_text(text: &str) -> Option<u32> {
7886 use std::sync::OnceLock;
7887 static RE: OnceLock<regex::Regex> = OnceLock::new();
7888 let lower = text.to_ascii_lowercase();
7889 let re = RE.get_or_init(|| {
7890 regex::Regex::new(r"(?i)\b(?:last|past)\s+(\d{1,3})\s*(hour|hours|hr|hrs)\b")
7891 .expect("valid")
7892 });
7893 if let Some(hours) = re
7894 .captures(&lower)
7895 .and_then(|captures| captures.get(1))
7896 .and_then(|m| m.as_str().parse::<u32>().ok())
7897 {
7898 return Some(hours);
7899 }
7900 if lower.contains("last hour") || lower.contains("past hour") {
7901 Some(1)
7902 } else if lower.contains("today") {
7903 Some(24)
7904 } else {
7905 None
7906 }
7907}
7908
7909fn extract_dns_record_type_from_shell(command: &str) -> Option<&'static str> {
7910 let lower = command.to_ascii_lowercase();
7911 if lower.contains("-type aaaa") || lower.contains("-type=aaaa") {
7912 Some("AAAA")
7913 } else if lower.contains("-type mx") || lower.contains("-type=mx") {
7914 Some("MX")
7915 } else if lower.contains("-type srv") || lower.contains("-type=srv") {
7916 Some("SRV")
7917 } else if lower.contains("-type txt") || lower.contains("-type=txt") {
7918 Some("TXT")
7919 } else if lower.contains("-type cname") || lower.contains("-type=cname") {
7920 Some("CNAME")
7921 } else if lower.contains("-type soa") || lower.contains("-type=soa") {
7922 Some("SOA")
7923 } else if lower.contains("-type ptr") || lower.contains("-type=ptr") {
7924 Some("PTR")
7925 } else if lower.contains("-type a") || lower.contains("-type=a") {
7926 Some("A")
7927 } else {
7928 extract_dns_record_type_from_text(command)
7929 }
7930}
7931
7932fn host_inspection_args_from_prompt(topic: &str, prompt: &str) -> Value {
7933 let mut args = serde_json::json!({ "topic": topic });
7934 if let Some(obj) = args.as_object_mut() {
7935 if topic == "dns_lookup" {
7936 if let Some(name) = extract_dns_lookup_target_from_text(prompt) {
7937 obj.insert("name".to_string(), Value::String(name));
7938 }
7939 let record_type = extract_dns_record_type_from_text(prompt).unwrap_or("A");
7940 obj.insert("type".to_string(), Value::String(record_type.to_string()));
7941 } else if topic == "event_query" {
7942 if let Some(event_id) = extract_event_query_event_id_from_text(prompt) {
7943 obj.insert(
7944 "event_id".to_string(),
7945 Value::Number(serde_json::Number::from(event_id)),
7946 );
7947 }
7948 if let Some(log_name) = extract_event_query_log_from_text(prompt) {
7949 obj.insert("log".to_string(), Value::String(log_name.to_string()));
7950 }
7951 if let Some(level) = extract_event_query_level_from_text(prompt) {
7952 obj.insert("level".to_string(), Value::String(level.to_string()));
7953 }
7954 if let Some(hours) = extract_event_query_hours_from_text(prompt) {
7955 obj.insert(
7956 "hours".to_string(),
7957 Value::Number(serde_json::Number::from(hours)),
7958 );
7959 }
7960 }
7961 }
7962 args
7963}
7964
7965fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
7966 let workflow = preferred_maintainer_workflow(prompt)?;
7967 let lower = prompt.to_ascii_lowercase();
7968 match workflow {
7969 "clean" => Some(serde_json::json!({
7970 "workflow": "clean",
7971 "deep": lower.contains("deep clean")
7972 || lower.contains("deep cleanup")
7973 || lower.contains("deep"),
7974 "reset": lower.contains("reset"),
7975 "prune_dist": lower.contains("prune dist")
7976 || lower.contains("prune old dist")
7977 || lower.contains("prune old artifacts")
7978 || lower.contains("old dist artifacts")
7979 || lower.contains("old artifacts"),
7980 })),
7981 "package_windows" => Some(serde_json::json!({
7982 "workflow": "package_windows",
7983 "installer": lower.contains("installer") || lower.contains("setup.exe"),
7984 "add_to_path": lower.contains("addtopath")
7985 || lower.contains("add to path")
7986 || lower.contains("update path")
7987 || lower.contains("refresh path"),
7988 })),
7989 "release" => {
7990 use std::sync::OnceLock;
7991 static SEMVER_RE: OnceLock<regex::Regex> = OnceLock::new();
7992 let version = SEMVER_RE
7993 .get_or_init(|| regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#).expect("valid"))
7994 .captures(prompt)
7995 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
7996 let bump = if lower.contains("patch") {
7997 Some("patch")
7998 } else if lower.contains("minor") {
7999 Some("minor")
8000 } else if lower.contains("major") {
8001 Some("major")
8002 } else {
8003 None
8004 };
8005 let mut args = serde_json::json!({
8006 "workflow": "release",
8007 "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
8008 "add_to_path": lower.contains("addtopath")
8009 || lower.contains("add to path")
8010 || lower.contains("update path"),
8011 "skip_installer": lower.contains("skip installer"),
8012 "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
8013 "publish_voice_crate": lower.contains("publish voice crate")
8014 || lower.contains("publish hematite-kokoros"),
8015 });
8016 if let Some(version) = version {
8017 args["version"] = Value::String(version);
8018 }
8019 if let Some(bump) = bump {
8020 args["bump"] = Value::String(bump.to_string());
8021 }
8022 Some(args)
8023 }
8024 _ => None,
8025 }
8026}
8027
8028fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
8029 if is_scaffold_request(prompt) {
8030 return None;
8031 }
8032 let workflow = preferred_workspace_workflow(prompt)?;
8033 let lower = prompt.to_ascii_lowercase();
8034 let trimmed = prompt.trim();
8035
8036 if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
8037 return Some(serde_json::json!({
8038 "workflow": "command",
8039 "command": command,
8040 }));
8041 }
8042
8043 if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
8044 return Some(serde_json::json!({
8045 "workflow": "script_path",
8046 "path": path,
8047 }));
8048 }
8049
8050 match workflow {
8051 "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
8052 "workflow": workflow,
8053 })),
8054 "script" => {
8055 let package_script = if lower.contains("npm run ") {
8056 extract_word_after(&lower, "npm run ")
8057 } else if lower.contains("pnpm run ") {
8058 extract_word_after(&lower, "pnpm run ")
8059 } else if lower.contains("bun run ") {
8060 extract_word_after(&lower, "bun run ")
8061 } else if lower.contains("yarn ") {
8062 extract_word_after(&lower, "yarn ")
8063 } else {
8064 None
8065 };
8066
8067 if let Some(name) = package_script {
8068 return Some(serde_json::json!({
8069 "workflow": "package_script",
8070 "name": name,
8071 }));
8072 }
8073
8074 if let Some(name) = extract_word_after(&lower, "just ") {
8075 return Some(serde_json::json!({
8076 "workflow": "just",
8077 "name": name,
8078 }));
8079 }
8080 if let Some(name) = extract_word_after(&lower, "make ") {
8081 return Some(serde_json::json!({
8082 "workflow": "make",
8083 "name": name,
8084 }));
8085 }
8086 if let Some(name) = extract_word_after(&lower, "task ") {
8087 return Some(serde_json::json!({
8088 "workflow": "task",
8089 "name": name,
8090 }));
8091 }
8092
8093 None
8094 }
8095 _ => None,
8096 }
8097}
8098
8099fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
8100 let lower = prompt.to_ascii_lowercase();
8101 for prefix in [
8102 "cargo ",
8103 "npm ",
8104 "pnpm ",
8105 "yarn ",
8106 "bun ",
8107 "pytest",
8108 "go build",
8109 "go test",
8110 "make ",
8111 "just ",
8112 "task ",
8113 "./gradlew",
8114 ".\\gradlew",
8115 ] {
8116 if let Some(index) = lower.find(prefix) {
8117 return Some(prompt[index..].trim().trim_matches('`').to_string());
8118 }
8119 }
8120 None
8121}
8122
8123fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
8124 let normalized = prompt.replace('\\', "/");
8125 for token in normalized.split_whitespace() {
8126 let candidate = token
8127 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8128 .trim_start_matches("./");
8129 if candidate.starts_with("scripts/")
8130 && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
8131 .iter()
8132 .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
8133 {
8134 return Some(candidate.to_string());
8135 }
8136 }
8137 None
8138}
8139
8140fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
8141 let start = haystack.find(prefix)? + prefix.len();
8142 let tail = &haystack[start..];
8143 let word = tail
8144 .split_whitespace()
8145 .next()
8146 .map(str::trim)
8147 .filter(|value| !value.is_empty())?;
8148 Some(
8149 word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
8150 .to_string(),
8151 )
8152}
8153
8154fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
8155 let lower = command.to_ascii_lowercase();
8156 if lower.contains("clean.ps1") {
8157 return Some(serde_json::json!({
8158 "workflow": "clean",
8159 "deep": lower.contains("-deep"),
8160 "reset": lower.contains("-reset"),
8161 "prune_dist": lower.contains("-prunedist"),
8162 }));
8163 }
8164 if lower.contains("package-windows.ps1") {
8165 return Some(serde_json::json!({
8166 "workflow": "package_windows",
8167 "installer": lower.contains("-installer"),
8168 "add_to_path": lower.contains("-addtopath"),
8169 }));
8170 }
8171 if lower.contains("release.ps1") {
8172 let version = extract_release_arg(command, "-Version");
8173 let bump = extract_release_arg(command, "-Bump");
8174 if version.is_none() && bump.is_none() {
8175 return Some(serde_json::json!({
8176 "workflow": "release"
8177 }));
8178 }
8179 let mut args = serde_json::json!({
8180 "workflow": "release",
8181 "push": lower.contains("-push"),
8182 "add_to_path": lower.contains("-addtopath"),
8183 "skip_installer": lower.contains("-skipinstaller"),
8184 "publish_crates": lower.contains("-publishcrates"),
8185 "publish_voice_crate": lower.contains("-publishvoicecrate"),
8186 });
8187 if let Some(version) = version {
8188 args["version"] = Value::String(version);
8189 }
8190 if let Some(bump) = bump {
8191 args["bump"] = Value::String(bump);
8192 }
8193 return Some(args);
8194 }
8195 None
8196}
8197
8198fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
8199 let lower = command.to_ascii_lowercase();
8200 if lower.contains("clean.ps1")
8201 || lower.contains("package-windows.ps1")
8202 || lower.contains("release.ps1")
8203 {
8204 return None;
8205 }
8206
8207 if let Some(path) = extract_workspace_script_path_from_prompt(command) {
8208 return Some(serde_json::json!({
8209 "workflow": "script_path",
8210 "path": path,
8211 }));
8212 }
8213
8214 let looks_like_workspace_command = [
8215 "cargo ",
8216 "npm ",
8217 "pnpm ",
8218 "yarn ",
8219 "bun ",
8220 "pytest",
8221 "go build",
8222 "go test",
8223 "make ",
8224 "just ",
8225 "task ",
8226 "./gradlew",
8227 ".\\gradlew",
8228 ]
8229 .iter()
8230 .any(|needle| lower.contains(needle));
8231
8232 if looks_like_workspace_command {
8233 Some(serde_json::json!({
8234 "workflow": "command",
8235 "command": command.trim(),
8236 }))
8237 } else {
8238 None
8239 }
8240}
8241
8242fn rewrite_host_tool_call(
8243 tool_name: &mut String,
8244 args: &mut Value,
8245 latest_user_prompt: Option<&str>,
8246) {
8247 if *tool_name == "shell" {
8248 let command = args
8249 .get("command")
8250 .and_then(|value| value.as_str())
8251 .unwrap_or("");
8252 if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
8253 *tool_name = "run_hematite_maintainer_workflow".to_string();
8254 *args = maintainer_workflow_args;
8255 return;
8256 }
8257 if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
8258 *tool_name = "run_workspace_workflow".to_string();
8259 *args = workspace_workflow_args;
8260 return;
8261 }
8262 }
8263 let is_surgical_tool = matches!(
8264 tool_name.as_str(),
8265 "create_directory"
8266 | "write_file"
8267 | "edit_file"
8268 | "patch_hunk"
8269 | "multi_replace_file_content"
8270 | "replace_file_content"
8271 | "move_file"
8272 | "delete_file"
8273 );
8274
8275 if !is_surgical_tool && *tool_name != "run_hematite_maintainer_workflow" {
8276 if let Some(prompt_args) =
8277 latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
8278 {
8279 *tool_name = "run_hematite_maintainer_workflow".to_string();
8280 *args = prompt_args;
8281 return;
8282 }
8283 }
8284 let is_generic_command_trigger = matches!(
8288 tool_name.as_str(),
8289 "shell" | "run_command" | "workflow" | "run"
8290 );
8291 if is_generic_command_trigger && *tool_name != "run_workspace_workflow" {
8292 if let Some(prompt_args) =
8293 latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
8294 {
8295 *tool_name = "run_workspace_workflow".to_string();
8296 *args = prompt_args;
8297 return;
8298 }
8299 }
8300 if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
8301 *tool_name = "inspect_host".to_string();
8302 *args = serde_json::json!({
8303 "topic": "fix_plan"
8304 });
8305 }
8306 fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
8307 fill_missing_dns_lookup_name(tool_name, args, latest_user_prompt);
8308 fill_missing_dns_lookup_type(tool_name, args, latest_user_prompt);
8309 fill_missing_event_query_args(tool_name, args, latest_user_prompt);
8310}
8311
8312fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
8313 format!(
8314 "{}:{}",
8315 tool_name,
8316 serde_json::to_string(args).unwrap_or_default()
8317 )
8318}
8319
8320fn normalized_tool_call_for_execution(
8321 tool_name: &str,
8322 raw_arguments: &Value,
8323 gemma4_model: bool,
8324 latest_user_prompt: Option<&str>,
8325) -> (String, Value) {
8326 let mut normalized_name = tool_name.to_string();
8327 let mut args = if gemma4_model {
8328 let raw_str = raw_arguments.to_string();
8329 let normalized_str =
8330 crate::agent::inference::normalize_tool_argument_string(tool_name, &raw_str);
8331 serde_json::from_str::<Value>(&normalized_str).unwrap_or_else(|_| raw_arguments.clone())
8332 } else {
8333 raw_arguments.clone()
8334 };
8335 rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
8336 (normalized_name, args)
8337}
8338
8339#[cfg(test)]
8340fn normalized_tool_call_key_for_dedupe(
8341 tool_name: &str,
8342 raw_arguments: &str,
8343 gemma4_model: bool,
8344 latest_user_prompt: Option<&str>,
8345) -> String {
8346 let val = serde_json::from_str(raw_arguments).unwrap_or(Value::Null);
8347 let (normalized_name, args) =
8348 normalized_tool_call_for_execution(tool_name, &val, gemma4_model, latest_user_prompt);
8349 canonical_tool_call_key(&normalized_name, &args)
8350}
8351
8352impl ConversationManager {
8353 fn check_authorization(
8355 &self,
8356 name: &str,
8357 args: &serde_json::Value,
8358 config: &crate::agent::config::HematiteConfig,
8359 yolo_flag: bool,
8360 ) -> crate::agent::permission_enforcer::AuthorizationDecision {
8361 crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
8362 }
8363
8364 async fn process_tool_call(
8366 &self,
8367 mut call: ToolCallFn,
8368 config: crate::agent::config::HematiteConfig,
8369 yolo: bool,
8370 tx: mpsc::Sender<InferenceEvent>,
8371 real_id: String,
8372 budget_tokens: usize,
8373 ) -> ToolExecutionOutcome {
8374 let mut msg_results = Vec::with_capacity(2);
8375 let mut latest_target_dir = None;
8376 let mut plan_drafted_this_turn = false;
8377 let mut parsed_plan_handoff = None;
8378 let gemma4_model =
8379 crate::agent::inference::is_hematite_native_model(&self.engine.current_model());
8380 let (normalized_name, mut args) = normalized_tool_call_for_execution(
8381 &call.name,
8382 &call.arguments,
8383 gemma4_model,
8384 self.history
8385 .last()
8386 .and_then(|m| m.content.as_str().split('\n').next_back()),
8387 );
8388 call.name = normalized_name;
8389 let last_user_prompt = self
8390 .history
8391 .iter()
8392 .rev()
8393 .find(|message| message.role == "user")
8394 .map(|message| message.content.as_str());
8395 rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
8396 if self
8397 .plan_execution_active
8398 .load(std::sync::atomic::Ordering::SeqCst)
8399 {
8400 let fallback_target = self
8401 .session_memory
8402 .current_plan
8403 .as_ref()
8404 .and_then(|plan| plan.target_files.first().map(String::as_str));
8405 let explicit_query = last_user_prompt.and_then(extract_explicit_web_search_query);
8406 if let Some((repaired_args, note)) = repaired_plan_tool_args(
8407 &call.name,
8408 &args,
8409 std::path::Path::new(".hematite/TASK.md").exists(),
8410 fallback_target,
8411 explicit_query.as_deref(),
8412 ) {
8413 args = repaired_args;
8414 let _ = tx.send(InferenceEvent::Thought(note)).await;
8415 }
8416 }
8417
8418 let display = format_tool_display(&call.name, &args);
8419 let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
8420 let auth = self.check_authorization(&call.name, &args, &config, yolo);
8421
8422 let decision_result = match precondition_result {
8424 Err(e) => Err(e),
8425 Ok(_) => match auth {
8426 crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
8427 crate::agent::permission_enforcer::AuthorizationDecision::Ask {
8428 reason,
8429 source: _,
8430 } => {
8431 let mutation_label =
8432 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8433 let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
8434 let _ = tx
8435 .send(InferenceEvent::ApprovalRequired {
8436 id: real_id.clone(),
8437 name: call.name.clone(),
8438 display: format!("{}\nWhy: {}", display, reason),
8439 diff: None,
8440 mutation_label,
8441 responder: approve_tx,
8442 })
8443 .await;
8444
8445 match approve_rx.await {
8446 Ok(true) => Ok(()),
8447 _ => Err("Declined by user".into()),
8448 }
8449 }
8450 crate::agent::permission_enforcer::AuthorizationDecision::Deny {
8451 reason, ..
8452 } => Err(reason),
8453 },
8454 };
8455 let blocked_by_policy =
8456 matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
8457
8458 let (output, is_error) = match decision_result {
8460 Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
8461 Err(e) => (format!("Error: {}", e), true),
8462 Ok(_) => {
8463 let _ = tx
8464 .send(InferenceEvent::ToolCallStart {
8465 id: real_id.clone(),
8466 name: call.name.clone(),
8467 args: display.clone(),
8468 })
8469 .await;
8470
8471 let result = if call.name.starts_with("lsp_") {
8472 let lsp = self.lsp_manager.clone();
8473 let path = args
8474 .get("path")
8475 .and_then(|v| v.as_str())
8476 .unwrap_or("")
8477 .to_string();
8478 let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8479 let character =
8480 args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
8481
8482 match call.name.as_str() {
8483 "lsp_definitions" => {
8484 crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
8485 .await
8486 }
8487 "lsp_references" => {
8488 crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
8489 .await
8490 }
8491 "lsp_hover" => {
8492 crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
8493 }
8494 "lsp_search_symbol" => {
8495 let query = args
8496 .get("query")
8497 .and_then(|v| v.as_str())
8498 .unwrap_or_default()
8499 .to_string();
8500 crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
8501 }
8502 "lsp_rename_symbol" => {
8503 let new_name = args
8504 .get("new_name")
8505 .and_then(|v| v.as_str())
8506 .unwrap_or_default()
8507 .to_string();
8508 crate::tools::lsp_tools::lsp_rename_symbol(
8509 lsp, path, line, character, new_name,
8510 )
8511 .await
8512 }
8513 "lsp_get_diagnostics" => {
8514 crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
8515 }
8516 _ => Err(format!("Unknown LSP tool: {}", call.name)),
8517 }
8518 } else if call.name == "auto_pin_context" {
8519 let pts = args.get("paths").and_then(|v| v.as_array());
8520 let reason = args
8521 .get("reason")
8522 .and_then(|v| v.as_str())
8523 .unwrap_or("uninformed scoping");
8524 if let Some(arr) = pts {
8525 let mut pinned = Vec::with_capacity(arr.len().min(3));
8526 {
8527 let mut guard = self.pinned_files.write().await;
8528 const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; for v in arr.iter().take(3) {
8531 if let Some(p) = v.as_str() {
8532 if let Ok(meta) = std::fs::metadata(p) {
8533 if meta.len() > MAX_PINNED_SIZE {
8534 let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
8535 continue;
8536 }
8537 if let Ok(content) = std::fs::read_to_string(p) {
8538 guard.insert(p.to_string(), content);
8539 pinned.push(p.to_string());
8540 }
8541 }
8542 }
8543 }
8544 }
8545 let msg = format!(
8546 "Autonomous Scoping: Locked {} in prioritized memory. Reason: {}",
8547 pinned.join(", "),
8548 reason
8549 );
8550 let _ = tx
8551 .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
8552 .await;
8553 Ok(msg)
8554 } else {
8555 Err("Missing 'paths' array for auto_pin_context.".to_string())
8556 }
8557 } else if call.name == "list_pinned" {
8558 let paths_msg = {
8559 let pinned = self.pinned_files.read().await;
8560 if pinned.is_empty() {
8561 "No files are currently pinned.".to_string()
8562 } else {
8563 let paths: Vec<_> = pinned.keys().cloned().collect();
8564 format!(
8565 "Currently pinned files in active memory:\n- {}",
8566 paths.join("\n- ")
8567 )
8568 }
8569 };
8570 Ok(paths_msg)
8571 } else if call.name.starts_with("mcp__") {
8572 let mut mcp = self.mcp_manager.lock().await;
8573 match mcp.call_tool(&call.name, &args).await {
8574 Ok(res) => Ok(res),
8575 Err(e) => Err(e.to_string()),
8576 }
8577 } else if call.name == "swarm" {
8578 let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
8580 let max_workers = args
8581 .get("max_workers")
8582 .and_then(|v| v.as_u64())
8583 .unwrap_or(3) as usize;
8584
8585 let mut task_objs = Vec::new();
8586 if let Value::Array(arr) = tasks_val {
8587 task_objs.reserve(arr.len());
8588 for v in arr {
8589 let id = v
8590 .get("id")
8591 .and_then(|x| x.as_str())
8592 .unwrap_or("?")
8593 .to_string();
8594 let target = v
8595 .get("target")
8596 .and_then(|x| x.as_str())
8597 .unwrap_or("?")
8598 .to_string();
8599 let instruction = v
8600 .get("instruction")
8601 .and_then(|x| x.as_str())
8602 .unwrap_or("?")
8603 .to_string();
8604 task_objs.push(crate::agent::parser::WorkerTask {
8605 id,
8606 target,
8607 instruction,
8608 });
8609 }
8610 }
8611
8612 if task_objs.is_empty() {
8613 Err("No tasks provided for swarm.".to_string())
8614 } else {
8615 let (swarm_tx_internal, mut swarm_rx_internal) =
8616 tokio::sync::mpsc::channel(32);
8617 let tx_forwarder = tx.clone();
8618
8619 tokio::spawn(async move {
8621 while let Some(msg) = swarm_rx_internal.recv().await {
8622 match msg {
8623 crate::agent::swarm::SwarmMessage::Progress(id, p) => {
8624 let _ = tx_forwarder
8625 .send(InferenceEvent::Thought(format!(
8626 "Swarm [{}]: {}% complete",
8627 id, p
8628 )))
8629 .await;
8630 }
8631 crate::agent::swarm::SwarmMessage::ReviewRequest {
8632 worker_id,
8633 file_path,
8634 before: _,
8635 after: _,
8636 tx,
8637 } => {
8638 let (approve_tx, approve_rx) =
8639 tokio::sync::oneshot::channel::<bool>();
8640 let display = format!(
8641 "Swarm worker [{}]: Integrated changes into {:?}",
8642 worker_id, file_path
8643 );
8644 let _ = tx_forwarder
8645 .send(InferenceEvent::ApprovalRequired {
8646 id: format!("swarm_{}", worker_id),
8647 name: "swarm_apply".to_string(),
8648 display,
8649 diff: None,
8650 mutation_label: Some(
8651 "Swarm Agentic Integration".to_string(),
8652 ),
8653 responder: approve_tx,
8654 })
8655 .await;
8656 if let Ok(approved) = approve_rx.await {
8657 let response = if approved {
8658 crate::agent::swarm::ReviewResponse::Accept
8659 } else {
8660 crate::agent::swarm::ReviewResponse::Reject
8661 };
8662 let _ = tx.send(response);
8663 }
8664 }
8665 crate::agent::swarm::SwarmMessage::Done => {}
8666 }
8667 }
8668 });
8669
8670 let coordinator = self.swarm_coordinator.clone();
8671 match coordinator
8672 .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
8673 .await
8674 {
8675 Ok(_) => Ok(
8676 "Swarm execution completed. Check files for integration results."
8677 .to_string(),
8678 ),
8679 Err(e) => Err(format!("Swarm failure: {}", e)),
8680 }
8681 }
8682 } else if call.name == "vision_analyze" {
8683 crate::tools::vision::vision_analyze(&self.engine, &args).await
8684 } else if matches!(
8685 call.name.as_str(),
8686 "edit_file" | "patch_hunk" | "multi_search_replace" | "write_file"
8687 ) && !yolo
8688 {
8689 let diff_result = match call.name.as_str() {
8695 "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
8696 "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
8697 "write_file" => crate::tools::file_ops::compute_write_file_diff(&args),
8698 _ => crate::tools::file_ops::compute_msr_diff(&args),
8699 };
8700 match diff_result {
8701 Ok(diff_text) => {
8702 let path_label =
8703 args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
8704 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
8705 let mutation_label =
8706 crate::agent::tool_registry::get_mutation_label(&call.name, &args);
8707 let _ = tx
8708 .send(InferenceEvent::ApprovalRequired {
8709 id: real_id.clone(),
8710 name: call.name.clone(),
8711 display: format!("Edit preview: {}", path_label),
8712 diff: Some(diff_text),
8713 mutation_label,
8714 responder: appr_tx,
8715 })
8716 .await;
8717 match appr_rx.await {
8718 Ok(true) => {
8719 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8720 }
8721 _ => Err("Edit declined by user.".into()),
8722 }
8723 }
8724 Err(_) => dispatch_tool(&call.name, &args, &config, budget_tokens).await,
8727 }
8728 } else if call.name == "verify_build" {
8729 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
8732 } else if call.name == "shell" {
8733 crate::tools::shell::execute_streaming(&args, tx.clone(), budget_tokens).await
8736 } else {
8737 dispatch_tool(&call.name, &args, &config, budget_tokens).await
8738 };
8739
8740 match result {
8741 Ok(o) => (o, false),
8742 Err(e) => (format!("Error: {}", e), true),
8743 }
8744 }
8745 };
8746
8747 {
8749 if let Ok(mut econ) = self.engine.economics.lock() {
8750 econ.record_tool(&call.name, !is_error);
8751 }
8752 }
8753
8754 if !is_error {
8755 if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
8756 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8757 if call.name == "inspect_lines" {
8758 self.record_line_inspection(path).await;
8759 } else {
8760 self.record_read_observation(path).await;
8761 }
8762 }
8763 }
8764
8765 if call.name == "verify_build" {
8766 let ok = output.contains("BUILD OK")
8767 || output.contains("BUILD SUCCESS")
8768 || output.contains("BUILD OKAY");
8769 self.record_verify_build_result(ok, &output).await;
8770 }
8771
8772 if matches!(
8773 call.name.as_str(),
8774 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
8775 ) || is_mcp_mutating_tool(&call.name)
8776 {
8777 if call.name == "write_file" {
8778 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8779 if path.ends_with("PLAN.md") {
8780 plan_drafted_this_turn = true;
8781 if !is_error {
8782 if let Some(content) = args.get("content").and_then(|v| v.as_str())
8783 {
8784 let resolved = crate::tools::file_ops::resolve_candidate(path);
8785 let _ = crate::tools::plan::sync_plan_blueprint_for_path(
8786 &resolved, content,
8787 );
8788 parsed_plan_handoff =
8789 crate::tools::plan::parse_plan_handoff(content);
8790 }
8791 }
8792 }
8793 }
8794 }
8795 self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
8796 .await;
8797 }
8798
8799 if call.name == "create_directory" {
8800 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
8801 let resolved = crate::tools::file_ops::resolve_candidate(path);
8802 latest_target_dir = Some(resolved.to_string_lossy().to_string());
8803 }
8804 }
8805
8806 if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
8807 msg_results.push(receipt);
8808 }
8809 }
8810
8811 if !is_error && !yolo && (call.name == "edit_file" || call.name == "write_file") {
8815 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
8816 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
8817 let ext = std::path::Path::new(path)
8818 .extension()
8819 .and_then(|e| e.to_str())
8820 .unwrap_or("");
8821 const SKIP_EXTS: &[&str] = &[
8822 "md",
8823 "toml",
8824 "json",
8825 "txt",
8826 "yml",
8827 "yaml",
8828 "cfg",
8829 "csv",
8830 "lock",
8831 "gitignore",
8832 ];
8833 let line_count = content.lines().count();
8834 const WEB_EXTS: &[&str] = &[
8837 "html", "htm", "css", "js", "ts", "jsx", "tsx", "vue", "svelte",
8838 ];
8839 let is_web = WEB_EXTS.contains(&ext);
8840 let min_lines = if is_web { 5 } else { 50 };
8841 if !path.is_empty()
8842 && !content.is_empty()
8843 && !SKIP_EXTS.contains(&ext)
8844 && line_count >= min_lines
8845 {
8846 if let Some(critique) = self.run_critic_check(path, content, &tx).await {
8847 msg_results.push(ChatMessage::system(&format!(
8848 "[CRITIC AUTO-FIX REQUIRED — {}]\n\
8849 Fix ALL issues below before sending your final response. \
8850 Call the appropriate edit tools now.\n\n{}",
8851 path, critique
8852 )));
8853 }
8854 }
8855 }
8856
8857 ToolExecutionOutcome {
8858 call_id: real_id,
8859 tool_name: call.name,
8860 args,
8861 output,
8862 is_error,
8863 blocked_by_policy,
8864 msg_results,
8865 latest_target_dir,
8866 plan_drafted_this_turn,
8867 parsed_plan_handoff,
8868 }
8869 }
8870}
8871
8872struct ToolExecutionOutcome {
8875 call_id: String,
8876 tool_name: String,
8877 args: Value,
8878 output: String,
8879 is_error: bool,
8880 blocked_by_policy: bool,
8881 msg_results: Vec<ChatMessage>,
8882 latest_target_dir: Option<String>,
8883 plan_drafted_this_turn: bool,
8884 parsed_plan_handoff: Option<crate::tools::plan::PlanHandoff>,
8885}
8886
8887#[derive(Clone)]
8888struct CachedToolResult {
8889 tool_name: String,
8890}
8891
8892fn is_code_like_path(path: &str) -> bool {
8893 let ext = std::path::Path::new(path)
8894 .extension()
8895 .and_then(|e| e.to_str())
8896 .unwrap_or("")
8897 .to_ascii_lowercase();
8898 matches!(
8899 ext.as_str(),
8900 "rs" | "js"
8901 | "ts"
8902 | "tsx"
8903 | "jsx"
8904 | "py"
8905 | "go"
8906 | "java"
8907 | "c"
8908 | "cpp"
8909 | "cc"
8910 | "h"
8911 | "hpp"
8912 | "cs"
8913 | "swift"
8914 | "kt"
8915 | "kts"
8916 | "rb"
8917 | "php"
8918 )
8919}
8920
8921pub fn format_tool_display(name: &str, args: &Value) -> String {
8924 let get = |key: &str| -> &str { args.get(key).and_then(|v| v.as_str()).unwrap_or("") };
8925 match name {
8926 "shell" | "bash" | "powershell" => format!("$ {}", get("command")),
8927 "run_workspace_workflow" => format!("workflow: {}", get("workflow")),
8928 "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
8929 "describe_toolchain" => format!("describe toolchain {}", get("topic")),
8930 "inspect_host" => format!("inspect host {}", get("topic")),
8931 "write_file"
8932 | "read_file"
8933 | "edit_file"
8934 | "patch_hunk"
8935 | "inspect_lines"
8936 | "lsp_get_diagnostics" => format!("{} `{}`", name, get("path")),
8937 "grep_files" => format!(
8938 "grep_files pattern='{}' path='{}'",
8939 get("pattern"),
8940 get("path")
8941 ),
8942 "list_files" => format!("list_files `{}`", get("path")),
8943 "multi_search_replace" => format!("multi_search_replace `{}`", get("path")),
8944 _ => {
8945 let rep = format!("{} {:?}", name, args);
8947 if rep.len() > 100 {
8948 format!("{}... (truncated)", safe_head(&rep, 100))
8949 } else {
8950 rep
8951 }
8952 }
8953 }
8954}
8955
8956pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
8959 let lower = command.to_ascii_lowercase();
8960 [
8961 "$env:path",
8962 "pathvariable",
8963 "pip --version",
8964 "pipx --version",
8965 "winget --version",
8966 "choco",
8967 "scoop",
8968 "get-childitem",
8969 "gci ",
8970 "where.exe",
8971 "where ",
8972 "cargo --version",
8973 "rustc --version",
8974 "git --version",
8975 "node --version",
8976 "npm --version",
8977 "pnpm --version",
8978 "python --version",
8979 "python3 --version",
8980 "deno --version",
8981 "go version",
8982 "dotnet --version",
8983 "uv --version",
8984 "netstat",
8985 "findstr",
8986 "get-nettcpconnection",
8987 "tcpconnection",
8988 "listening",
8989 "ss -",
8990 "ss ",
8991 "lsof",
8992 "tasklist",
8993 "ipconfig",
8994 "get-netipconfiguration",
8995 "get-netadapter",
8996 "route print",
8997 "ifconfig",
8998 "ip addr",
8999 "ip route",
9000 "resolv.conf",
9001 "get-service",
9002 "sc query",
9003 "systemctl",
9004 "service --status-all",
9005 "get-process",
9006 "working set",
9007 "ps -eo",
9008 "ps aux",
9009 "desktop",
9010 "downloads",
9011 "get-netfirewallprofile",
9012 "win32_powerplan",
9013 "win32_operatingsystem",
9014 "win32_processor",
9015 "wmic",
9016 "loadpercentage",
9017 "totalvisiblememory",
9018 "freephysicalmemory",
9019 "get-wmiobject",
9020 "get-ciminstance",
9021 "get-cpu",
9022 "processorname",
9023 "clockspeed",
9024 "top memory",
9025 "top cpu",
9026 "resource usage",
9027 "powercfg",
9028 "uptime",
9029 "lastbootuptime",
9030 "hklm:",
9032 "hkcu:",
9033 "hklm:\\",
9034 "hkcu:\\",
9035 "currentversion",
9036 "productname",
9037 "displayversion",
9038 "get-itemproperty",
9039 "get-itempropertyvalue",
9040 "get-windowsupdatelog",
9042 "windowsupdatelog",
9043 "microsoft.update.session",
9044 "createupdatesearcher",
9045 "wuauserv",
9046 "usoclient",
9047 "get-hotfix",
9048 "wu_",
9049 "get-mpcomputerstatus",
9051 "get-mppreference",
9052 "get-mpthreat",
9053 "start-mpscan",
9054 "win32_computersecurity",
9055 "softwarelicensingproduct",
9056 "enablelua",
9057 "get-netfirewallrule",
9058 "netfirewallprofile",
9059 "antivirus",
9060 "defenderstatus",
9061 "get-physicaldisk",
9063 "get-disk",
9064 "get-volume",
9065 "get-psdrive",
9066 "psdrive",
9067 "manage-bde",
9068 "bitlockervolume",
9069 "get-bitlockervolume",
9070 "get-smbencryptionstatus",
9071 "smbencryption",
9072 "get-netlanmanagerconnection",
9073 "lanmanager",
9074 "msstoragedriver_failurepredic",
9075 "win32_diskdrive",
9076 "smartstatus",
9077 "diskstatus",
9078 "get-counter",
9079 "intensity",
9080 "benchmark",
9081 "thrash",
9082 "get-item",
9083 "test-path",
9084 "gpresult",
9086 "applied gpo",
9087 "cert:\\",
9088 "cert:",
9089 "component based servicing",
9090 "componentstore",
9091 "get-computerinfo",
9092 "win32_computersystem",
9093 "win32_battery",
9095 "batterystaticdata",
9096 "batteryfullchargedcapacity",
9097 "batterystatus",
9098 "estimatedchargeremaining",
9099 "get-winevent",
9101 "eventid",
9102 "bugcheck",
9103 "kernelpower",
9104 "win32_ntlogevent",
9105 "filterhashtable",
9106 "get-scheduledtask",
9108 "get-scheduledtaskinfo",
9109 "schtasks",
9110 "taskscheduler",
9111 "get-acl",
9112 "icacls",
9113 "takeown",
9114 "event id 4624",
9115 "eventid 4624",
9116 "who logged in",
9117 "logon history",
9118 "login history",
9119 "get-smbshare",
9120 "net share",
9121 "mbps",
9122 "throughput",
9123 "whoami",
9124 "get-ciminstance win32",
9126 "get-wmiobject win32",
9127 "arp -",
9129 "arp -a",
9130 "tracert ",
9131 "traceroute ",
9132 "tracepath ",
9133 "get-dnsclientcache",
9134 "ipconfig /displaydns",
9135 "get-netroute",
9136 "get-netneighbor",
9137 "net view",
9138 "get-smbconnection",
9139 "get-smbmapping",
9140 "get-psdrive",
9141 "fdrespub",
9142 "fdphost",
9143 "ssdpsrv",
9144 "upnphost",
9145 "avahi-browse",
9146 "route print",
9147 "ip neigh",
9148 "get-pnpdevice -class audioendpoint",
9150 "get-pnpdevice -class media",
9151 "win32_sounddevice",
9152 "audiosrv",
9153 "audioendpointbuilder",
9154 "windows audio",
9155 "get-pnpdevice -class bluetooth",
9156 "bthserv",
9157 "bthavctpsvc",
9158 "btagservice",
9159 "bluetoothuserservice",
9160 "msiserver",
9161 "appxsvc",
9162 "clipsvc",
9163 "installservice",
9164 "desktopappinstaller",
9165 "microsoft.windowsstore",
9166 "get-appxpackage microsoft.desktopappinstaller",
9167 "get-appxpackage microsoft.windowsstore",
9168 "winget source",
9169 "winget --info",
9170 "onedrive",
9171 "onedrive.exe",
9172 "files on-demand",
9173 "known folder backup",
9174 "disablefilesyncngsc",
9175 "kfmsilentoptin",
9176 "kfmblockoptin",
9177 "get-process chrome",
9178 "get-process msedge",
9179 "get-process firefox",
9180 "get-process msedgewebview2",
9181 "google chrome",
9182 "microsoft edge",
9183 "mozilla firefox",
9184 "webview2",
9185 "msedgewebview2",
9186 "startmenuinternet",
9187 "urlassociations\\http\\userchoice",
9188 "urlassociations\\https\\userchoice",
9189 "software\\policies\\microsoft\\edge",
9190 "software\\policies\\google\\chrome",
9191 "get-winevent",
9192 "event id",
9193 "eventlog",
9194 "event viewer",
9195 "wevtutil",
9196 "cmdkey",
9197 "credential manager",
9198 "get-tpm",
9199 "confirm-securebootuefi",
9200 "win32_tpm",
9201 "dsregcmd",
9202 "webauthmanager",
9203 "web account manager",
9204 "tokenbroker",
9205 "token broker",
9206 "aad broker",
9207 "brokerplugin",
9208 "microsoft.aad.brokerplugin",
9209 "workplace join",
9210 "device registration",
9211 "secure boot",
9212 "get-aduser",
9214 "get-addomain",
9215 "get-adforest",
9216 "get-adgroup",
9217 "get-adcomputer",
9218 "activedirectory",
9219 "get-localuser",
9220 "get-localgroup",
9221 "get-localgroupmember",
9222 "net user",
9223 "net localgroup",
9224 "netsh winhttp show proxy",
9225 "get-itemproperty.*proxy",
9226 "get-netadapter",
9227 "netsh wlan show",
9228 "test-netconnection",
9229 "resolve-dnsname",
9230 "nslookup",
9231 "dig ",
9232 "gethostentry",
9233 "gethostaddresses",
9234 "getipaddresses",
9235 "[system.net.dns]",
9236 "net.dns]",
9237 "get-netfirewallrule",
9238 "docker ps",
9240 "docker info",
9241 "docker images",
9242 "docker container",
9243 "docker inspect",
9244 "docker volume",
9245 "docker system df",
9246 "docker compose ls",
9247 "wsl --list",
9248 "wsl -l",
9249 "wsl --status",
9250 "wsl --version",
9251 "wsl -d",
9252 "wsl df",
9253 "wsl du",
9254 "/mnt/c",
9255 "ssh -v",
9256 "get-service sshd",
9257 "get-service -name sshd",
9258 "cat ~/.ssh",
9259 "ls ~/.ssh",
9260 "ls -la ~/.ssh",
9261 "get-childitem env:",
9263 "dir env:",
9264 "printenv",
9265 "[environment]::getenvironmentvariable",
9266 "get-content.*hosts",
9267 "cat /etc/hosts",
9268 "type c:\\windows\\system32\\drivers\\etc\\hosts",
9269 "git config --global --list",
9270 "git config --list",
9271 "git config --global",
9272 "get-service mysql",
9274 "get-service postgresql",
9275 "get-service mongodb",
9276 "get-service redis",
9277 "get-service mssql",
9278 "get-service mariadb",
9279 "systemctl status postgresql",
9280 "systemctl status mysql",
9281 "systemctl status mongod",
9282 "systemctl status redis",
9283 "winget list",
9285 "get-package",
9286 "get-itempropert.*uninstall",
9287 "dpkg --get-selections",
9288 "rpm -qa",
9289 "brew list",
9290 "get-localuser",
9292 "get-localgroupmember",
9293 "net user",
9294 "query user",
9295 "net localgroup administrators",
9296 "auditpol /get",
9298 "auditpol",
9299 "get-smbshare",
9301 "get-smbserverconfiguration",
9302 "net share",
9303 "net use",
9304 "get-dnsclientserveraddress",
9306 "get-dnsclientdohserveraddress",
9307 "get-dnsclientglobalsetting",
9308 ]
9309 .iter()
9310 .any(|needle| lower.contains(needle))
9311 || lower.starts_with("host ")
9312}
9313
9314fn cap_output(text: &str, max_bytes: usize) -> String {
9317 cap_output_for_tool(text, max_bytes, "output")
9318}
9319
9320fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
9325 if text.len() <= max_bytes {
9326 return text.to_string();
9327 }
9328
9329 let scratch_path = write_output_to_scratch(text, tool_name);
9331
9332 let mut split_at = max_bytes;
9333 while !text.is_char_boundary(split_at) && split_at > 0 {
9334 split_at -= 1;
9335 }
9336
9337 let tail = match &scratch_path {
9338 Some(p) => format!(
9339 "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
9340 text.len(),
9341 text.lines().count(),
9342 p
9343 ),
9344 None => format!("\n... [output capped at {}B]", max_bytes),
9345 };
9346
9347 format!("{}{}", &text[..split_at], tail)
9348}
9349
9350fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
9353 let scratch_dir = crate::tools::file_ops::hematite_dir().join("scratch");
9354 if std::fs::create_dir_all(&scratch_dir).is_err() {
9355 return None;
9356 }
9357 let ts = std::time::SystemTime::now()
9358 .duration_since(std::time::UNIX_EPOCH)
9359 .map(|d| d.as_secs())
9360 .unwrap_or(0);
9361 let safe_name: String = tool_name
9363 .chars()
9364 .map(|c| {
9365 if c.is_alphanumeric() || c == '_' {
9366 c
9367 } else {
9368 '_'
9369 }
9370 })
9371 .collect();
9372 let filename = format!("{}_{}.txt", safe_name, ts);
9373 let abs_path = scratch_dir.join(&filename);
9374 if std::fs::write(&abs_path, text).is_err() {
9375 return None;
9376 }
9377 Some(format!(".hematite/scratch/{}", filename))
9378}
9379
9380#[derive(Default)]
9381struct PromptBudgetStats {
9382 summarized_tool_results: usize,
9383 collapsed_tool_results: usize,
9384 trimmed_chat_messages: usize,
9385 dropped_messages: usize,
9386}
9387
9388fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
9389 crate::agent::inference::estimate_message_batch_tokens(messages)
9390}
9391
9392fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
9393 let budget = compaction::SummaryCompressionBudget {
9394 max_chars,
9395 max_lines: 3,
9396 max_line_chars: max_chars.clamp(80, 240),
9397 };
9398 let compressed = compaction::compress_summary(text, budget).summary;
9399 if compressed.is_empty() {
9400 String::new()
9401 } else {
9402 compressed
9403 }
9404}
9405
9406fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
9407 let tool_name = message.name.as_deref().unwrap_or("tool");
9408 let body = summarize_prompt_blob(message.content.as_str(), 320);
9409 format!(
9410 "[Prompt-budget summary of prior `{}` result]\n{}",
9411 tool_name, body
9412 )
9413}
9414
9415fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
9416 let role = message.role.as_str();
9417 let body = summarize_prompt_blob(message.content.as_str(), 240);
9418 format!(
9419 "[Prompt-budget summary of earlier {} message]\n{}",
9420 role, body
9421 )
9422}
9423
9424fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
9425 if messages.len() > 1 && messages[1].role != "user" {
9426 messages.insert(1, ChatMessage::user("Continuing previous context..."));
9427 }
9428}
9429
9430fn enforce_prompt_budget(
9431 prompt_msgs: &mut Vec<ChatMessage>,
9432 context_length: usize,
9433) -> Option<String> {
9434 let target_tokens = ((context_length as f64) * 0.68) as usize;
9435 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9436 return None;
9437 }
9438
9439 let mut stats = PromptBudgetStats::default();
9440
9441 let mut tool_indices: Vec<usize> = {
9443 let mut v = Vec::with_capacity(prompt_msgs.len());
9444 v.extend(
9445 prompt_msgs
9446 .iter()
9447 .enumerate()
9448 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx)),
9449 );
9450 v
9451 };
9452 for idx in tool_indices.iter().rev().copied() {
9453 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9454 break;
9455 }
9456 let original = prompt_msgs[idx].content.as_str().to_string();
9457 if original.len() > 1200 {
9458 prompt_msgs[idx].content =
9459 MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
9460 stats.summarized_tool_results += 1;
9461 }
9462 }
9463
9464 tool_indices.clear();
9466 tool_indices.extend(
9467 prompt_msgs
9468 .iter()
9469 .enumerate()
9470 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx)),
9471 );
9472 if tool_indices.len() > 2 {
9473 for idx in tool_indices
9474 .iter()
9475 .take(tool_indices.len().saturating_sub(2))
9476 .copied()
9477 {
9478 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9479 break;
9480 }
9481 prompt_msgs[idx].content = MessageContent::Text(
9482 "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
9483 );
9484 stats.collapsed_tool_results += 1;
9485 }
9486 }
9487
9488 let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9490 for idx in 1..prompt_msgs.len() {
9491 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
9492 break;
9493 }
9494 if Some(idx) == last_user_idx {
9495 continue;
9496 }
9497 let role = prompt_msgs[idx].role.as_str();
9498 if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
9499 prompt_msgs[idx].content =
9500 MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
9501 stats.trimmed_chat_messages += 1;
9502 }
9503 }
9504
9505 let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
9507 let mut idx = 1usize;
9508 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9509 if idx >= prompt_msgs.len() {
9510 break;
9511 }
9512
9513 let role = prompt_msgs[idx].role.as_str();
9514 if role == "user" || Some(idx) == preserve_last_user_idx {
9515 idx += 1;
9517 continue;
9518 }
9519
9520 prompt_msgs.remove(idx);
9522 stats.dropped_messages += 1;
9523 }
9524
9525 let mut idx = 1usize;
9527 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
9528 if Some(idx) == preserve_last_user_idx {
9529 idx += 1;
9530 if idx >= prompt_msgs.len() {
9531 break;
9532 }
9533 continue;
9534 }
9535 if idx >= prompt_msgs.len() {
9536 break;
9537 }
9538 prompt_msgs.remove(idx);
9539 stats.dropped_messages += 1;
9540 }
9541
9542 normalize_prompt_start(prompt_msgs);
9543
9544 let new_tokens = estimate_prompt_tokens(prompt_msgs);
9545 if stats.summarized_tool_results == 0
9546 && stats.collapsed_tool_results == 0
9547 && stats.trimmed_chat_messages == 0
9548 && stats.dropped_messages == 0
9549 {
9550 return None;
9551 }
9552
9553 Some(format!(
9554 "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).",
9555 new_tokens,
9556 target_tokens,
9557 stats.summarized_tool_results,
9558 stats.collapsed_tool_results,
9559 stats.trimmed_chat_messages,
9560 stats.dropped_messages
9561 ))
9562}
9563
9564fn is_quick_tool_request(input: &str) -> bool {
9569 let lower = input.to_lowercase();
9570 if lower.contains("run_code") || lower.contains("run code") {
9572 return true;
9573 }
9574 let is_short = input.len() < 120;
9576 let compute_keywords = [
9577 "calculate",
9578 "compute",
9579 "execute",
9580 "run this",
9581 "test this",
9582 "what is ",
9583 "how much",
9584 "how many",
9585 "convert ",
9586 "print ",
9587 ];
9588 if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
9589 return true;
9590 }
9591 false
9592}
9593
9594fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
9595 let avg_word = 6usize;
9596 let mut chunks = Vec::with_capacity(text.len() / (words_per_chunk * avg_word).max(1) + 1);
9597 let mut current = String::with_capacity(words_per_chunk * avg_word);
9598 let mut count = 0;
9599
9600 for ch in text.chars() {
9601 current.push(ch);
9602 if ch == ' ' || ch == '\n' {
9603 count += 1;
9604 if count >= words_per_chunk {
9605 chunks.push(std::mem::take(&mut current));
9606 current = String::with_capacity(words_per_chunk * avg_word);
9607 count = 0;
9608 }
9609 }
9610 }
9611 if !current.is_empty() {
9612 chunks.push(current);
9613 }
9614 chunks
9615}
9616
9617fn repaired_plan_tool_args(
9618 tool_name: &str,
9619 args: &Value,
9620 task_file_exists: bool,
9621 fallback_target: Option<&str>,
9622 explicit_query: Option<&str>,
9623) -> Option<(Value, String)> {
9624 match tool_name {
9625 "read_file" | "inspect_lines" => {
9626 let has_path = args
9627 .as_object()
9628 .and_then(|map| map.get("path"))
9629 .and_then(|v| v.as_str())
9630 .map(|s| !s.trim().is_empty())
9631 .unwrap_or(false);
9632 if has_path {
9633 return None;
9634 }
9635
9636 let target = if task_file_exists {
9637 Some(".hematite/TASK.md")
9638 } else {
9639 fallback_target
9640 }?;
9641 let mut repaired = if args.is_object() {
9642 args.clone()
9643 } else {
9644 Value::Object(serde_json::Map::new())
9645 };
9646 let map = repaired.as_object_mut()?;
9647 map.insert("path".to_string(), Value::String(target.to_string()));
9648 Some((
9649 repaired,
9650 format!(
9651 "Recovered malformed `{}` call during current-plan execution by grounding it to `{}`.",
9652 tool_name, target
9653 ),
9654 ))
9655 }
9656 "research_web" => {
9657 let has_query = args
9658 .as_object()
9659 .and_then(|map| map.get("query"))
9660 .and_then(|v| v.as_str())
9661 .map(|s| !s.trim().is_empty())
9662 .unwrap_or(false);
9663 if has_query {
9664 return None;
9665 }
9666 let query = explicit_query?.trim();
9667 if query.is_empty() {
9668 return None;
9669 }
9670 let mut repaired = if args.is_object() {
9671 args.clone()
9672 } else {
9673 Value::Object(serde_json::Map::new())
9674 };
9675 let map = repaired.as_object_mut()?;
9676 map.insert("query".to_string(), Value::String(query.to_string()));
9677 Some((
9678 repaired,
9679 format!(
9680 "Recovered malformed `research_web` call during current-plan execution by restoring query `{}`.",
9681 query
9682 ),
9683 ))
9684 }
9685 _ => None,
9686 }
9687}
9688
9689fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
9690 if call.name != "read_file" {
9691 return None;
9692 }
9693 let mut args = call.arguments.clone();
9694 crate::agent::inference::normalize_tool_argument_value(&call.name, &mut args);
9695 let path = args.get("path").and_then(|v| v.as_str())?;
9696 Some(normalize_workspace_path(path))
9697}
9698
9699fn order_batch_reads_first(
9700 calls: Vec<crate::agent::inference::ToolCallResponse>,
9701) -> (
9702 Vec<crate::agent::inference::ToolCallResponse>,
9703 Option<String>,
9704) {
9705 let has_reads = calls.iter().any(|c| {
9706 matches!(
9707 c.function.name.as_str(),
9708 "read_file" | "inspect_lines" | "grep_files" | "list_files"
9709 )
9710 });
9711 let has_edits = calls.iter().any(|c| {
9712 matches!(
9713 c.function.name.as_str(),
9714 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9715 )
9716 });
9717 if has_reads && has_edits {
9718 let reads: Vec<_> = calls
9719 .into_iter()
9720 .filter(|c| {
9721 !matches!(
9722 c.function.name.as_str(),
9723 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
9724 )
9725 })
9726 .collect();
9727 let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
9728 (reads, note)
9729 } else {
9730 (calls, None)
9731 }
9732}
9733
9734fn grep_output_is_high_fanout(output: &str) -> bool {
9735 let Some(summary) = output.lines().next() else {
9736 return false;
9737 };
9738 let hunk_count = summary
9739 .split(", ")
9740 .find_map(|part| {
9741 part.strip_suffix(" hunk(s)")
9742 .and_then(|value| value.parse::<usize>().ok())
9743 })
9744 .unwrap_or(0);
9745 let match_count = summary
9746 .split(' ')
9747 .next()
9748 .and_then(|value| value.parse::<usize>().ok())
9749 .unwrap_or(0);
9750 hunk_count >= 8 || match_count >= 12
9751}
9752
9753fn build_system_with_corrections(
9754 base: &str,
9755 hints: &[String],
9756 gpu: &Arc<GpuState>,
9757 git: &Arc<crate::agent::git_monitor::GitState>,
9758 config: &crate::agent::config::HematiteConfig,
9759) -> String {
9760 let mut system_msg = base.to_string();
9761
9762 system_msg.push_str("\n\n# Permission Mode\n");
9764 let mode_label = match config.mode {
9765 crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
9766 crate::agent::config::PermissionMode::Developer => "DEVELOPER",
9767 crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
9768 };
9769 let _ = writeln!(system_msg, "CURRENT MODE: {}", mode_label);
9770
9771 if config.mode == crate::agent::config::PermissionMode::ReadOnly {
9772 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");
9773 } else {
9774 system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
9775 }
9776
9777 let (used, total) = gpu.read();
9779 if total > 0 {
9780 system_msg.push_str("\n\n# Terminal Hardware Context\n");
9781 let _ = writeln!(
9782 system_msg,
9783 "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)",
9784 gpu.gpu_name(),
9785 used as f64 / 1024.0,
9786 total as f64 / 1024.0,
9787 gpu.ratio() * 100.0
9788 );
9789 system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
9790 }
9791
9792 system_msg.push_str("\n\n# Git Repository Context\n");
9794 let git_status_label = git.label();
9795 let git_url = git.url();
9796 let _ = writeln!(
9797 system_msg,
9798 "REMOTE STATUS: {} | URL: {}",
9799 git_status_label, git_url
9800 );
9801
9802 let root = crate::tools::file_ops::workspace_root();
9804 if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
9805 system_msg.push_str("\nGit status snapshot:\n");
9806 system_msg.push_str(&status_snapshot);
9807 system_msg.push('\n');
9808 }
9809
9810 if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
9811 system_msg.push_str("\nGit diff snapshot:\n");
9812 system_msg.push_str(&diff_snapshot);
9813 system_msg.push('\n');
9814 }
9815
9816 if git_status_label == "NONE" {
9817 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");
9818 } else if git_status_label == "BEHIND" {
9819 system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
9820 }
9821
9822 if hints.is_empty() {
9827 return system_msg;
9828 }
9829 system_msg.push_str("\n\n# Formatting Corrections\n");
9830 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");
9831 for hint in hints {
9832 let _ = writeln!(system_msg, "- {}", hint);
9833 }
9834 system_msg
9835}
9836
9837fn route_model<'a>(
9838 user_input: &str,
9839 fast_model: Option<&'a str>,
9840 think_model: Option<&'a str>,
9841) -> Option<&'a str> {
9842 let text = user_input.to_lowercase();
9843 let is_think = text.contains("refactor")
9844 || text.contains("rewrite")
9845 || text.contains("implement")
9846 || text.contains("create")
9847 || text.contains("fix")
9848 || text.contains("debug");
9849 let is_fast = text.contains("what")
9850 || text.contains("show")
9851 || text.contains("find")
9852 || text.contains("list")
9853 || text.contains("status");
9854
9855 if is_think && think_model.is_some() {
9856 return think_model;
9857 } else if is_fast && fast_model.is_some() {
9858 return fast_model;
9859 }
9860 None
9861}
9862
9863fn is_parallel_safe(name: &str) -> bool {
9864 let metadata = crate::agent::inference::tool_metadata_for_name(name);
9865 !metadata.mutates_workspace && !metadata.external_surface
9866}
9867
9868fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
9869 if docs_only_mode {
9870 return true;
9871 }
9872
9873 let lower = query.to_ascii_lowercase();
9874 [
9875 "what did we decide",
9876 "why did we decide",
9877 "what did we say",
9878 "what did we do",
9879 "earlier today",
9880 "yesterday",
9881 "last week",
9882 "last month",
9883 "earlier",
9884 "remember",
9885 "session",
9886 "import",
9887 ]
9888 .iter()
9889 .any(|needle| lower.contains(needle))
9890 || lower
9891 .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
9892 .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
9893}
9894
9895#[cfg(test)]
9896mod tests {
9897 use super::*;
9898
9899 #[test]
9900 fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
9901 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."}"#;
9902 let class = classify_runtime_failure(detail);
9903 assert_eq!(class, RuntimeFailureClass::ContextWindow);
9904 assert_eq!(class.tag(), "context_window");
9905 assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
9906 }
9907
9908 #[test]
9909 fn formatted_runtime_failure_is_not_wrapped_twice() {
9910 let detail =
9911 "[failure:provider_degraded] Retry once automatically, then narrow the turn or restart LM Studio if it persists. Detail: LMS unreachable: Request failed";
9912 let formatted = format_runtime_failure(RuntimeFailureClass::ProviderDegraded, detail);
9913 assert_eq!(formatted, detail);
9914 assert_eq!(formatted.matches("[failure:provider_degraded]").count(), 1);
9915 }
9916
9917 #[test]
9918 fn explicit_search_detection_requires_search_language() {
9919 assert!(is_explicit_web_search_request("search for ocean bennett"));
9920 assert!(is_explicit_web_search_request("google ocean bennett"));
9921 assert!(is_explicit_web_search_request("look up ocean bennett"));
9922 assert!(!is_explicit_web_search_request("who is ocean bennett"));
9923 }
9924
9925 #[test]
9926 fn explicit_search_query_extracts_leading_search_clause_from_mixed_request() {
9927 assert_eq!(
9928 extract_explicit_web_search_query(
9929 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it"
9930 ),
9931 Some("uefn toolbelt".to_string())
9932 );
9933 }
9934
9935 #[test]
9936 fn auto_research_handover_is_turn_scoped_only() {
9937 assert!(should_use_turn_scoped_investigation_mode(
9938 WorkflowMode::Auto,
9939 QueryIntentClass::Research
9940 ));
9941 assert!(!should_use_turn_scoped_investigation_mode(
9942 WorkflowMode::Ask,
9943 QueryIntentClass::Research
9944 ));
9945 assert!(!should_use_turn_scoped_investigation_mode(
9946 WorkflowMode::Auto,
9947 QueryIntentClass::RepoArchitecture
9948 ));
9949 }
9950
9951 #[test]
9952 fn research_provider_fallback_mentions_direct_search_results() {
9953 let fallback = build_research_provider_fallback(
9954 "[Source: SearXNG]\n\n### 1. [Ocean Bennett](https://example.com)\nBio",
9955 );
9956 assert!(fallback.contains("Local web search succeeded"));
9957 assert!(fallback.contains("[Source: SearXNG]"));
9958 assert!(fallback.contains("Ocean Bennett"));
9959 }
9960
9961 #[test]
9962 fn runtime_failure_maps_to_provider_and_checkpoint_state() {
9963 assert_eq!(
9964 provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9965 Some(ProviderRuntimeState::ContextWindow)
9966 );
9967 assert_eq!(
9968 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
9969 Some(OperatorCheckpointState::BlockedContextWindow)
9970 );
9971 assert_eq!(
9972 provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9973 Some(ProviderRuntimeState::Degraded)
9974 );
9975 assert_eq!(
9976 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
9977 None
9978 );
9979 }
9980
9981 #[test]
9982 fn intent_router_treats_tool_registry_ownership_as_product_truth() {
9983 let intent = classify_query_intent(
9984 WorkflowMode::ReadOnly,
9985 "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
9986 );
9987 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
9988 assert_eq!(
9989 intent.direct_answer,
9990 Some(DirectAnswerKind::ToolRegistryOwnership)
9991 );
9992 }
9993
9994 #[test]
9995 fn intent_router_treats_tool_classes_as_product_truth() {
9996 let intent = classify_query_intent(
9997 WorkflowMode::ReadOnly,
9998 "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.",
9999 );
10000 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
10001 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
10002 }
10003
10004 #[test]
10005 fn tool_registry_ownership_answer_mentions_new_owner_file() {
10006 let answer = build_tool_registry_ownership_answer();
10007 assert!(answer.contains("src/agent/tool_registry.rs"));
10008 assert!(answer.contains("builtin dispatch path"));
10009 assert!(answer.contains("src/agent/conversation.rs"));
10010 }
10011
10012 #[test]
10013 fn intent_router_treats_mcp_lifecycle_as_product_truth() {
10014 let intent = classify_query_intent(
10015 WorkflowMode::ReadOnly,
10016 "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
10017 );
10018 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
10019 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
10020 }
10021
10022 #[test]
10023 fn intent_router_short_circuits_unsafe_commit_pressure() {
10024 let intent = classify_query_intent(
10025 WorkflowMode::Auto,
10026 "Make a code change, skip verification, and commit it immediately.",
10027 );
10028 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
10029 assert_eq!(
10030 intent.direct_answer,
10031 Some(DirectAnswerKind::UnsafeWorkflowPressure)
10032 );
10033 }
10034
10035 #[test]
10036 fn unsafe_workflow_pressure_answer_requires_verification() {
10037 let answer = build_unsafe_workflow_pressure_answer();
10038 assert!(answer.contains("should not skip verification"));
10039 assert!(answer.contains("run the appropriate verification path"));
10040 assert!(answer.contains("only then commit"));
10041 }
10042
10043 #[test]
10044 fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
10045 let intent = classify_query_intent(
10046 WorkflowMode::ReadOnly,
10047 "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.",
10048 );
10049 assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
10050 assert!(intent.architecture_overview_mode);
10051 assert_eq!(intent.direct_answer, None);
10052 }
10053
10054 #[test]
10055 fn intent_router_marks_host_inspection_questions() {
10056 let intent = classify_query_intent(
10057 WorkflowMode::Auto,
10058 "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.",
10059 );
10060 assert!(intent.host_inspection_mode);
10061 assert_eq!(
10062 preferred_host_inspection_topic(
10063 "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."
10064 ),
10065 Some("summary")
10066 );
10067 }
10068
10069 #[test]
10070 fn intent_router_treats_purpose_question_as_local_identity() {
10071 let intent = classify_query_intent(WorkflowMode::Auto, "What is your purpose?");
10072 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::Identity));
10073 }
10074
10075 #[test]
10076 fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
10077 assert!(should_use_vein_in_chat(
10078 "What did we decide on 2026-04-09 about docs-only mode?",
10079 false
10080 ));
10081 assert!(should_use_vein_in_chat("Summarize these local notes", true));
10082 assert!(!should_use_vein_in_chat("Tell me a joke", false));
10083 }
10084
10085 #[test]
10086 fn shell_host_inspection_guard_matches_path_and_version_commands() {
10087 assert!(shell_looks_like_structured_host_inspection(
10088 "$env:PATH -split ';'"
10089 ));
10090 assert!(shell_looks_like_structured_host_inspection(
10091 "cargo --version"
10092 ));
10093 assert!(shell_looks_like_structured_host_inspection(
10094 "Get-NetTCPConnection -LocalPort 3000"
10095 ));
10096 assert!(shell_looks_like_structured_host_inspection(
10097 "netstat -ano | findstr :3000"
10098 ));
10099 assert!(shell_looks_like_structured_host_inspection(
10100 "Get-Process | Sort-Object WS -Descending"
10101 ));
10102 assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
10103 assert!(shell_looks_like_structured_host_inspection("Get-Service"));
10104 assert!(shell_looks_like_structured_host_inspection(
10105 "winget --version"
10106 ));
10107 assert!(shell_looks_like_structured_host_inspection(
10108 "wsl df -h && wsl du -sh /mnt/c 2>&1 | head -5"
10109 ));
10110 assert!(shell_looks_like_structured_host_inspection(
10111 "Get-NetNeighbor -AddressFamily IPv4"
10112 ));
10113 assert!(shell_looks_like_structured_host_inspection(
10114 "Get-SmbConnection"
10115 ));
10116 assert!(shell_looks_like_structured_host_inspection(
10117 "Get-Service FDResPub,fdPHost,SSDPSRV,upnphost"
10118 ));
10119 assert!(shell_looks_like_structured_host_inspection(
10120 "Get-PnpDevice -Class AudioEndpoint"
10121 ));
10122 assert!(shell_looks_like_structured_host_inspection(
10123 "Get-CimInstance Win32_SoundDevice"
10124 ));
10125 assert!(shell_looks_like_structured_host_inspection(
10126 "Get-PnpDevice -Class Bluetooth"
10127 ));
10128 assert!(shell_looks_like_structured_host_inspection(
10129 "Get-Service bthserv,BthAvctpSvc,BTAGService"
10130 ));
10131 assert!(shell_looks_like_structured_host_inspection(
10132 "Get-Service msiserver,AppXSvc,ClipSVC,InstallService"
10133 ));
10134 assert!(shell_looks_like_structured_host_inspection(
10135 "Get-AppxPackage Microsoft.DesktopAppInstaller"
10136 ));
10137 assert!(shell_looks_like_structured_host_inspection(
10138 "winget source list"
10139 ));
10140 assert!(shell_looks_like_structured_host_inspection(
10141 "Get-Process OneDrive"
10142 ));
10143 assert!(shell_looks_like_structured_host_inspection(
10144 "Get-ItemProperty HKCU:\\Software\\Microsoft\\OneDrive\\Accounts"
10145 ));
10146 assert!(shell_looks_like_structured_host_inspection("cmdkey /list"));
10147 assert!(shell_looks_like_structured_host_inspection("Get-Tpm"));
10148 assert!(shell_looks_like_structured_host_inspection(
10149 "Confirm-SecureBootUEFI"
10150 ));
10151 assert!(shell_looks_like_structured_host_inspection(
10152 "dsregcmd /status"
10153 ));
10154 assert!(shell_looks_like_structured_host_inspection(
10155 "Get-Service TokenBroker,wlidsvc,OneAuth"
10156 ));
10157 assert!(shell_looks_like_structured_host_inspection(
10158 "Get-AppxPackage Microsoft.AAD.BrokerPlugin"
10159 ));
10160 assert!(shell_looks_like_structured_host_inspection(
10161 "host github.com"
10162 ));
10163 assert!(shell_looks_like_structured_host_inspection(
10164 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10165 ));
10166 }
10167
10168 #[test]
10169 fn dns_shell_target_extraction_handles_common_lookup_forms() {
10170 assert_eq!(
10171 extract_dns_lookup_target_from_shell("host github.com").as_deref(),
10172 Some("github.com")
10173 );
10174 assert_eq!(
10175 extract_dns_lookup_target_from_shell(
10176 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10177 )
10178 .as_deref(),
10179 Some("github.com")
10180 );
10181 assert_eq!(
10182 extract_dns_lookup_target_from_shell(
10183 "powershell -Command \"$ip = [System.Net.Dns]::GetHostAddresses('github.com'); $ip | ForEach-Object { $_.Address }\""
10184 )
10185 .as_deref(),
10186 Some("github.com")
10187 );
10188 }
10189
10190 #[test]
10191 fn dns_prompt_target_extraction_handles_plain_english_questions() {
10192 assert_eq!(
10193 extract_dns_lookup_target_from_text("Show me the A record for github.com").as_deref(),
10194 Some("github.com")
10195 );
10196 assert_eq!(
10197 extract_dns_lookup_target_from_text("What is the IP address of google.com").as_deref(),
10198 Some("google.com")
10199 );
10200 }
10201
10202 #[test]
10203 fn dns_record_type_extraction_handles_prompt_and_shell_forms() {
10204 assert_eq!(
10205 extract_dns_record_type_from_text("Show me the A record for github.com"),
10206 Some("A")
10207 );
10208 assert_eq!(
10209 extract_dns_record_type_from_text("What is the IP address of google.com"),
10210 Some("A")
10211 );
10212 assert_eq!(
10213 extract_dns_record_type_from_text("Resolve the MX record for example.com"),
10214 Some("MX")
10215 );
10216 assert_eq!(
10217 extract_dns_record_type_from_shell(
10218 "powershell -Command \"Resolve-DnsName -Name github.com -Type A\""
10219 ),
10220 Some("A")
10221 );
10222 assert_eq!(
10223 extract_dns_record_type_from_shell("nslookup -type=mx example.com"),
10224 Some("MX")
10225 );
10226 }
10227
10228 #[test]
10229 fn fill_missing_dns_lookup_name_backfills_from_latest_user_prompt() {
10230 let mut tool_name = "inspect_host".to_string();
10231 let mut args = serde_json::json!({
10232 "topic": "dns_lookup"
10233 });
10234 rewrite_host_tool_call(
10235 &mut tool_name,
10236 &mut args,
10237 Some("Show me the A record for github.com"),
10238 );
10239 assert_eq!(tool_name, "inspect_host");
10240 assert_eq!(
10241 args.get("name").and_then(|value| value.as_str()),
10242 Some("github.com")
10243 );
10244 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10245 }
10246
10247 #[test]
10248 fn host_inspection_args_from_prompt_populates_dns_lookup_fields() {
10249 let args =
10250 host_inspection_args_from_prompt("dns_lookup", "What is the IP address of google.com");
10251 assert_eq!(
10252 args.get("name").and_then(|value| value.as_str()),
10253 Some("google.com")
10254 );
10255 assert_eq!(args.get("type").and_then(|value| value.as_str()), Some("A"));
10256 }
10257
10258 #[test]
10259 fn host_inspection_args_from_prompt_populates_event_query_fields() {
10260 let args = host_inspection_args_from_prompt(
10261 "event_query",
10262 "Show me all System errors from the Event Log that occurred in the last 4 hours.",
10263 );
10264 assert_eq!(
10265 args.get("log").and_then(|value| value.as_str()),
10266 Some("System")
10267 );
10268 assert_eq!(
10269 args.get("level").and_then(|value| value.as_str()),
10270 Some("Error")
10271 );
10272 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10273 }
10274
10275 #[test]
10276 fn fill_missing_event_query_args_backfills_from_latest_user_prompt() {
10277 let mut tool_name = "inspect_host".to_string();
10278 let mut args = serde_json::json!({
10279 "topic": "event_query"
10280 });
10281 rewrite_host_tool_call(
10282 &mut tool_name,
10283 &mut args,
10284 Some("Show me all System errors from the Event Log that occurred in the last 4 hours."),
10285 );
10286 assert_eq!(tool_name, "inspect_host");
10287 assert_eq!(
10288 args.get("log").and_then(|value| value.as_str()),
10289 Some("System")
10290 );
10291 assert_eq!(
10292 args.get("level").and_then(|value| value.as_str()),
10293 Some("Error")
10294 );
10295 assert_eq!(args.get("hours").and_then(|value| value.as_u64()), Some(4));
10296 }
10297
10298 #[test]
10299 fn intent_router_picks_ports_for_listening_port_questions() {
10300 assert_eq!(
10301 preferred_host_inspection_topic(
10302 "Show me what is listening on port 3000 and whether anything unexpected is exposed."
10303 ),
10304 Some("ports")
10305 );
10306 }
10307
10308 #[test]
10309 fn intent_router_picks_processes_for_host_process_questions() {
10310 assert_eq!(
10311 preferred_host_inspection_topic(
10312 "Show me what processes are using the most RAM right now."
10313 ),
10314 Some("processes")
10315 );
10316 }
10317
10318 #[test]
10319 fn intent_router_picks_network_for_adapter_questions() {
10320 assert_eq!(
10321 preferred_host_inspection_topic(
10322 "Show me my active network adapters, IP addresses, gateways, and DNS servers."
10323 ),
10324 Some("network")
10325 );
10326 }
10327
10328 #[test]
10329 fn intent_router_picks_services_for_service_questions() {
10330 assert_eq!(
10331 preferred_host_inspection_topic(
10332 "Show me the running services and startup types that matter for a normal dev machine."
10333 ),
10334 Some("services")
10335 );
10336 }
10337
10338 #[test]
10339 fn intent_router_picks_env_doctor_for_package_manager_questions() {
10340 assert_eq!(
10341 preferred_host_inspection_topic(
10342 "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
10343 ),
10344 Some("env_doctor")
10345 );
10346 }
10347
10348 #[test]
10349 fn intent_router_picks_fix_plan_for_host_remediation_questions() {
10350 assert_eq!(
10351 preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
10352 Some("fix_plan")
10353 );
10354 assert_eq!(
10355 preferred_host_inspection_topic(
10356 "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
10357 ),
10358 Some("fix_plan")
10359 );
10360 }
10361
10362 #[test]
10363 fn intent_router_picks_audio_for_sound_and_microphone_questions() {
10364 assert_eq!(
10365 preferred_host_inspection_topic("Why is there no sound from my speakers right now?"),
10366 Some("audio")
10367 );
10368 assert_eq!(
10369 preferred_host_inspection_topic(
10370 "Check my microphone and playback devices because Windows Audio seems broken."
10371 ),
10372 Some("audio")
10373 );
10374 }
10375
10376 #[test]
10377 fn intent_router_picks_bluetooth_for_pairing_and_headset_questions() {
10378 assert_eq!(
10379 preferred_host_inspection_topic(
10380 "Why won't this Bluetooth headset pair and stay connected?"
10381 ),
10382 Some("bluetooth")
10383 );
10384 assert_eq!(
10385 preferred_host_inspection_topic("Check my Bluetooth radio and pairing status."),
10386 Some("bluetooth")
10387 );
10388 }
10389
10390 #[test]
10391 fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
10392 let mut args = serde_json::json!({
10393 "topic": "fix_plan"
10394 });
10395
10396 fill_missing_fix_plan_issue(
10397 "inspect_host",
10398 &mut args,
10399 Some("/think\nHow do I fix cargo not found on this machine?"),
10400 );
10401
10402 assert_eq!(
10403 args.get("issue").and_then(|value| value.as_str()),
10404 Some("How do I fix cargo not found on this machine?")
10405 );
10406 }
10407
10408 #[test]
10409 fn shell_fix_question_rewrites_to_fix_plan() {
10410 let args = serde_json::json!({
10411 "command": "where cargo"
10412 });
10413
10414 assert!(should_rewrite_shell_to_fix_plan(
10415 "shell",
10416 &args,
10417 Some("How do I fix cargo not found on this machine?")
10418 ));
10419 }
10420
10421 #[test]
10422 fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
10423 let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
10424 let shell_key = normalized_tool_call_key_for_dedupe(
10425 "shell",
10426 r#"{"command":"where cargo"}"#,
10427 false,
10428 latest_user_prompt,
10429 );
10430 let fix_plan_key = normalized_tool_call_key_for_dedupe(
10431 "inspect_host",
10432 r#"{"topic":"fix_plan"}"#,
10433 false,
10434 latest_user_prompt,
10435 );
10436
10437 assert_eq!(shell_key, fix_plan_key);
10438 }
10439
10440 #[test]
10441 fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
10442 let (tool_name, args) = normalized_tool_call_for_execution(
10443 "shell",
10444 &serde_json::json!({"command":"pwsh ./clean.ps1 -Deep -PruneDist"}),
10445 false,
10446 Some("Run my cleanup scripts."),
10447 );
10448
10449 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10450 assert_eq!(
10451 args.get("workflow").and_then(|value| value.as_str()),
10452 Some("clean")
10453 );
10454 assert_eq!(
10455 args.get("deep").and_then(|value| value.as_bool()),
10456 Some(true)
10457 );
10458 assert_eq!(
10459 args.get("prune_dist").and_then(|value| value.as_bool()),
10460 Some(true)
10461 );
10462 }
10463
10464 #[test]
10465 fn shell_release_script_rewrites_to_maintainer_workflow() {
10466 let (tool_name, args) = normalized_tool_call_for_execution(
10467 "shell",
10468 &serde_json::json!({"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}),
10469 false,
10470 Some("Run the release flow."),
10471 );
10472
10473 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10474 assert_eq!(
10475 args.get("workflow").and_then(|value| value.as_str()),
10476 Some("release")
10477 );
10478 assert_eq!(
10479 args.get("version").and_then(|value| value.as_str()),
10480 Some("0.4.5")
10481 );
10482 assert_eq!(
10483 args.get("push").and_then(|value| value.as_bool()),
10484 Some(true)
10485 );
10486 }
10487
10488 #[test]
10489 fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
10490 let (tool_name, args) = normalized_tool_call_for_execution(
10491 "shell",
10492 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10493 false,
10494 Some("Run the deep cleanup and prune old dist artifacts."),
10495 );
10496
10497 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
10498 assert_eq!(
10499 args.get("workflow").and_then(|value| value.as_str()),
10500 Some("clean")
10501 );
10502 assert_eq!(
10503 args.get("deep").and_then(|value| value.as_bool()),
10504 Some(true)
10505 );
10506 assert_eq!(
10507 args.get("prune_dist").and_then(|value| value.as_bool()),
10508 Some(true)
10509 );
10510 }
10511
10512 #[test]
10513 fn shell_cargo_test_rewrites_to_workspace_workflow() {
10514 let (tool_name, args) = normalized_tool_call_for_execution(
10515 "shell",
10516 &serde_json::json!({"command":"cargo test"}),
10517 false,
10518 Some("Run cargo test in this project."),
10519 );
10520
10521 assert_eq!(tool_name, "run_workspace_workflow");
10522 assert_eq!(
10523 args.get("workflow").and_then(|value| value.as_str()),
10524 Some("command")
10525 );
10526 assert_eq!(
10527 args.get("command").and_then(|value| value.as_str()),
10528 Some("cargo test")
10529 );
10530 }
10531
10532 #[test]
10533 fn current_plan_execution_request_accepts_saved_plan_command() {
10534 assert!(is_current_plan_execution_request("/implement-plan"));
10535 assert!(is_current_plan_execution_request(
10536 "Implement the current plan."
10537 ));
10538 }
10539
10540 #[test]
10541 fn architect_operator_note_points_to_execute_path() {
10542 let plan = crate::tools::plan::PlanHandoff {
10543 goal: "Tighten startup workflow guidance".into(),
10544 target_files: vec!["src/runtime.rs".into()],
10545 ordered_steps: vec!["Update the startup banner".into()],
10546 verification: "cargo check --tests".into(),
10547 risks: vec![],
10548 open_questions: vec![],
10549 };
10550 let note = architect_handoff_operator_note(&plan);
10551 assert!(note.contains("`.hematite/PLAN.md`"));
10552 assert!(note.contains("/implement-plan"));
10553 assert!(note.contains("/code implement the current plan"));
10554 }
10555
10556 #[test]
10557 fn sovereign_scaffold_handoff_carries_explicit_research_step() {
10558 let mut targets = std::collections::BTreeSet::new();
10559 targets.insert("index.html".to_string());
10560 let plan = build_sovereign_scaffold_handoff(
10561 "google uefn toolbelt then make a folder on my desktop called oupa with a single file html website talking about it",
10562 &targets,
10563 );
10564
10565 assert!(plan
10566 .ordered_steps
10567 .iter()
10568 .any(|step| step.contains("research_web")));
10569 assert!(plan
10570 .ordered_steps
10571 .iter()
10572 .any(|step| step.contains("uefn toolbelt")));
10573 }
10574
10575 #[test]
10576 fn single_file_html_sovereign_targets_only_index() {
10577 let targets = default_sovereign_scaffold_targets(
10578 "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",
10579 );
10580
10581 assert!(targets.contains("index.html"));
10582 assert!(!targets.contains("style.css"));
10583 assert!(!targets.contains("script.js"));
10584 }
10585
10586 #[test]
10587 fn single_file_html_handoff_verification_mentions_self_contained_index() {
10588 let mut targets = std::collections::BTreeSet::new();
10589 targets.insert("index.html".to_string());
10590 let plan = build_sovereign_scaffold_handoff(
10591 "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",
10592 &targets,
10593 );
10594
10595 assert!(plan.verification.contains("index.html"));
10596 assert!(plan.verification.contains("self-contained"));
10597 assert!(plan
10598 .ordered_steps
10599 .iter()
10600 .any(|step| step.contains("single `index.html` file")));
10601 }
10602
10603 #[test]
10604 fn plan_handoff_mentions_tool_detects_research_steps() {
10605 let plan = crate::tools::plan::PlanHandoff {
10606 goal: "Build the site".into(),
10607 target_files: vec!["index.html".into()],
10608 ordered_steps: vec!["Use `research_web` first to gather context.".into()],
10609 verification: "verify_build(action: \"build\")".into(),
10610 risks: vec![],
10611 open_questions: vec![],
10612 };
10613
10614 assert!(plan_handoff_mentions_tool(&plan, "research_web"));
10615 assert!(!plan_handoff_mentions_tool(&plan, "fetch_docs"));
10616 }
10617
10618 #[test]
10619 fn parse_task_checklist_progress_counts_checked_items() {
10620 let progress = parse_task_checklist_progress(
10621 r#"
10622- [x] Build the landing page shell
10623- [ ] Wire the responsive nav
10624* [X] Add hero section copy
10625Plain paragraph
10626"#,
10627 );
10628
10629 assert_eq!(progress.total, 3);
10630 assert_eq!(progress.completed, 2);
10631 assert_eq!(progress.remaining, 1);
10632 assert!(progress.has_open_items());
10633 }
10634
10635 #[test]
10636 fn merge_plan_allowed_paths_includes_hematite_sidecars() {
10637 let allowed = merge_plan_allowed_paths(&["src/main.rs".to_string()]);
10638
10639 assert!(allowed.iter().any(|p| p.ends_with("/src/main.rs")));
10643 assert!(allowed
10644 .iter()
10645 .any(|path| path.ends_with("/.hematite/task.md")));
10646 assert!(allowed
10647 .iter()
10648 .any(|path| path.ends_with("/.hematite/plan.md")));
10649 }
10650
10651 #[test]
10652 fn repaired_plan_tool_args_recovers_empty_read_to_task_ledger() {
10653 let args = serde_json::json!({});
10654 let (repaired, note) =
10655 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10656
10657 assert_eq!(
10658 repaired.get("path").and_then(|v| v.as_str()),
10659 Some(".hematite/TASK.md")
10660 );
10661 assert!(note.contains(".hematite/TASK.md"));
10662 }
10663
10664 #[test]
10665 fn repaired_plan_tool_args_recovers_empty_research_query() {
10666 let args = serde_json::json!({});
10667 let (repaired, note) = repaired_plan_tool_args(
10668 "research_web",
10669 &args,
10670 true,
10671 Some("index.html"),
10672 Some("uefn toolbelt"),
10673 )
10674 .unwrap();
10675
10676 assert_eq!(
10677 repaired.get("query").and_then(|v| v.as_str()),
10678 Some("uefn toolbelt")
10679 );
10680 assert!(note.contains("uefn toolbelt"));
10681 }
10682
10683 #[test]
10684 fn repaired_plan_tool_args_recovers_non_object_read_call() {
10685 let args = serde_json::json!("");
10686 let (repaired, _) =
10687 repaired_plan_tool_args("read_file", &args, true, Some("index.html"), None).unwrap();
10688
10689 assert_eq!(
10690 repaired.get("path").and_then(|v| v.as_str()),
10691 Some(".hematite/TASK.md")
10692 );
10693 }
10694
10695 #[test]
10696 fn force_plan_mutation_prompt_names_target_files() {
10697 let prompt = build_force_plan_mutation_prompt(
10698 TaskChecklistProgress {
10699 total: 5,
10700 completed: 0,
10701 remaining: 5,
10702 },
10703 &["index.html".to_string()],
10704 );
10705
10706 assert!(prompt.contains(".hematite/TASK.md"));
10707 assert!(prompt.contains("`index.html`"));
10708 assert!(prompt.contains("Do not summarize"));
10709 }
10710
10711 #[test]
10712 fn current_plan_scope_recovery_prompt_names_saved_targets() {
10713 let prompt = build_current_plan_scope_recovery_prompt(&["index.html".to_string()]);
10714
10715 assert!(prompt.contains("`index.html`"));
10716 assert!(prompt.contains(".hematite/TASK.md"));
10717 assert!(prompt.contains("Do not branch into unrelated files"));
10718 }
10719
10720 #[test]
10721 fn task_ledger_closeout_prompt_demands_checklist_update() {
10722 let prompt = build_task_ledger_closeout_prompt(
10723 TaskChecklistProgress {
10724 total: 5,
10725 completed: 0,
10726 remaining: 5,
10727 },
10728 &["index.html".to_string()],
10729 );
10730
10731 assert!(prompt.contains(".hematite/TASK.md"));
10732 assert!(prompt.contains("`index.html`"));
10733 assert!(prompt.contains("Do not summarize"));
10734 assert!(prompt.contains("`[x]`"));
10735 }
10736
10737 #[test]
10738 fn suppresses_recoverable_blocked_tool_result_only_when_redirect_exists() {
10739 assert!(should_suppress_recoverable_tool_result(true, true));
10740 assert!(!should_suppress_recoverable_tool_result(true, false));
10741 assert!(!should_suppress_recoverable_tool_result(false, true));
10742 }
10743
10744 #[test]
10745 fn sovereign_closeout_detects_materialized_targets() {
10746 let _cwd_lock = crate::TEST_CWD_LOCK
10747 .lock()
10748 .unwrap_or_else(|e| e.into_inner());
10749 let temp = tempfile::tempdir().unwrap();
10750 let previous = env!("CARGO_MANIFEST_DIR");
10751 std::env::set_current_dir(temp.path()).unwrap();
10752 std::fs::write("index.html", "<html>ok</html>").unwrap();
10753
10754 assert!(target_files_materialized(&["index.html".to_string()]));
10755
10756 std::env::set_current_dir(previous).unwrap();
10757 }
10758
10759 #[test]
10760 fn deterministic_sovereign_closeout_returns_summary_when_targets_exist() {
10761 let _cwd_lock = crate::TEST_CWD_LOCK
10762 .lock()
10763 .unwrap_or_else(|e| e.into_inner());
10764 let temp = tempfile::tempdir().unwrap();
10765 let previous = env!("CARGO_MANIFEST_DIR");
10766 std::env::set_current_dir(temp.path()).unwrap();
10767 std::fs::create_dir_all(".hematite").unwrap();
10768 std::fs::write("index.html", "<html>ok</html>").unwrap();
10769 std::fs::write(".hematite/TASK.md", "# Task Ledger\n\n- [ ] Build index\n").unwrap();
10770 std::fs::write(".hematite/WALKTHROUGH.md", "").unwrap();
10771
10772 let plan = crate::tools::plan::PlanHandoff {
10773 goal: "Continue the sovereign scaffold task in this new project root".to_string(),
10774 target_files: vec!["index.html".to_string()],
10775 ordered_steps: vec!["Build index".to_string()],
10776 verification: "Open index.html".to_string(),
10777 risks: vec![],
10778 open_questions: vec![],
10779 };
10780
10781 let summary = maybe_deterministic_sovereign_closeout(Some(&plan), true).unwrap();
10782 let task = std::fs::read_to_string(".hematite/TASK.md").unwrap();
10783
10784 std::env::set_current_dir(previous).unwrap();
10785
10786 assert!(summary.contains("Sovereign Scaffold Task Complete"));
10787 assert!(task.contains("- [x] Build index"));
10788 }
10789
10790 #[test]
10791 fn continue_plan_execution_requires_progress_and_open_items() {
10792 let mut mutated = std::collections::BTreeSet::new();
10793 mutated.insert("index.html".to_string());
10794
10795 assert!(should_continue_plan_execution(
10796 1,
10797 Some(TaskChecklistProgress {
10798 total: 3,
10799 completed: 1,
10800 remaining: 2,
10801 }),
10802 Some(TaskChecklistProgress {
10803 total: 3,
10804 completed: 2,
10805 remaining: 1,
10806 }),
10807 &mutated,
10808 ));
10809
10810 assert!(!should_continue_plan_execution(
10811 1,
10812 Some(TaskChecklistProgress {
10813 total: 3,
10814 completed: 2,
10815 remaining: 1,
10816 }),
10817 Some(TaskChecklistProgress {
10818 total: 3,
10819 completed: 2,
10820 remaining: 1,
10821 }),
10822 &std::collections::BTreeSet::new(),
10823 ));
10824
10825 assert!(!should_continue_plan_execution(
10826 6,
10827 Some(TaskChecklistProgress {
10828 total: 3,
10829 completed: 2,
10830 remaining: 1,
10831 }),
10832 Some(TaskChecklistProgress {
10833 total: 3,
10834 completed: 3,
10835 remaining: 0,
10836 }),
10837 &mutated,
10838 ));
10839 }
10840
10841 #[test]
10842 fn website_validation_runs_for_website_contract_frontend_paths() {
10843 let contract = crate::agent::workspace_profile::RuntimeContract {
10844 loop_family: "website".to_string(),
10845 app_kind: "website".to_string(),
10846 framework_hint: Some("vite".to_string()),
10847 preferred_workflows: vec!["website_validate".to_string()],
10848 delivery_phases: vec!["design".to_string(), "validate".to_string()],
10849 verification_workflows: vec!["build".to_string(), "website_validate".to_string()],
10850 quality_gates: vec!["critical routes return HTTP 200".to_string()],
10851 local_url_hint: Some("http://127.0.0.1:5173/".to_string()),
10852 route_hints: vec!["/".to_string()],
10853 };
10854 let mutated = std::collections::BTreeSet::from([
10855 "src/pages/index.tsx".to_string(),
10856 "public/app.css".to_string(),
10857 ]);
10858 assert!(should_run_website_validation(Some(&contract), &mutated));
10859 }
10860
10861 #[test]
10862 fn website_validation_skips_non_website_contracts() {
10863 let contract = crate::agent::workspace_profile::RuntimeContract {
10864 loop_family: "service".to_string(),
10865 app_kind: "node-service".to_string(),
10866 framework_hint: Some("express".to_string()),
10867 preferred_workflows: vec!["build".to_string()],
10868 delivery_phases: vec!["define boundary".to_string()],
10869 verification_workflows: vec!["build".to_string()],
10870 quality_gates: vec!["build stays green".to_string()],
10871 local_url_hint: None,
10872 route_hints: Vec::new(),
10873 };
10874 let mutated = std::collections::BTreeSet::from(["server.ts".to_string()]);
10875 assert!(!should_run_website_validation(Some(&contract), &mutated));
10876 assert!(!should_run_website_validation(None, &mutated));
10877 }
10878
10879 #[test]
10880 fn repeat_guard_exempts_structured_website_validation() {
10881 assert!(is_repeat_guard_exempt_tool_call(
10882 "run_workspace_workflow",
10883 &serde_json::json!({ "workflow": "website_validate" }),
10884 ));
10885 assert!(!is_repeat_guard_exempt_tool_call(
10886 "run_workspace_workflow",
10887 &serde_json::json!({ "workflow": "build" }),
10888 ));
10889 }
10890
10891 #[test]
10892 fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
10893 let (tool_name, args) = normalized_tool_call_for_execution(
10894 "shell",
10895 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10896 false,
10897 Some("Run the tests in this project."),
10898 );
10899
10900 assert_eq!(tool_name, "run_workspace_workflow");
10901 assert_eq!(
10902 args.get("workflow").and_then(|value| value.as_str()),
10903 Some("test")
10904 );
10905 }
10906
10907 #[test]
10908 fn scaffold_prompt_does_not_rewrite_to_workspace_workflow() {
10909 let (tool_name, _args) = normalized_tool_call_for_execution(
10910 "shell",
10911 &serde_json::json!({"command":"powershell -Command \"Get-ChildItem .\""}),
10912 false,
10913 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."),
10914 );
10915
10916 assert_eq!(tool_name, "shell");
10917 }
10918
10919 #[test]
10920 fn failing_path_parser_extracts_cargo_error_locations() {
10921 let output = r#"
10922BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
10923
10924error[E0412]: cannot find type `Foo` in this scope
10925 --> src/agent/conversation.rs:42:12
10926 |
1092742 | field: Foo,
10928 | ^^^ not found
10929
10930error[E0308]: mismatched types
10931 --> src/tools/file_ops.rs:100:5
10932 |
10933 = note: expected `String`, found `&str`
10934"#;
10935 let paths = parse_failing_paths_from_build_output(output);
10936 assert!(
10937 paths.iter().any(|p| p.contains("conversation.rs")),
10938 "should capture conversation.rs"
10939 );
10940 assert!(
10941 paths.iter().any(|p| p.contains("file_ops.rs")),
10942 "should capture file_ops.rs"
10943 );
10944 assert_eq!(paths.len(), 2, "no duplicates");
10945 }
10946
10947 #[test]
10948 fn failing_path_parser_ignores_macro_expansions() {
10949 let output = r#"
10950 --> <macro-expansion>:1:2
10951 --> src/real/file.rs:10:5
10952"#;
10953 let paths = parse_failing_paths_from_build_output(output);
10954 assert_eq!(paths.len(), 1);
10955 assert!(paths[0].contains("file.rs"));
10956 }
10957
10958 #[test]
10959 fn intent_router_picks_updates_for_update_questions() {
10960 assert_eq!(
10961 preferred_host_inspection_topic("is my PC up to date?"),
10962 Some("updates")
10963 );
10964 assert_eq!(
10965 preferred_host_inspection_topic("are there any pending Windows updates?"),
10966 Some("updates")
10967 );
10968 assert_eq!(
10969 preferred_host_inspection_topic("check for updates on my computer"),
10970 Some("updates")
10971 );
10972 }
10973
10974 #[test]
10975 fn intent_router_picks_security_for_antivirus_questions() {
10976 assert_eq!(
10977 preferred_host_inspection_topic("is my antivirus on?"),
10978 Some("security")
10979 );
10980 assert_eq!(
10981 preferred_host_inspection_topic("is Windows Defender running?"),
10982 Some("security")
10983 );
10984 assert_eq!(
10985 preferred_host_inspection_topic("is my PC protected?"),
10986 Some("security")
10987 );
10988 }
10989
10990 #[test]
10991 fn intent_router_picks_pending_reboot_for_restart_questions() {
10992 assert_eq!(
10993 preferred_host_inspection_topic("do I need to restart my PC?"),
10994 Some("pending_reboot")
10995 );
10996 assert_eq!(
10997 preferred_host_inspection_topic("is a reboot required?"),
10998 Some("pending_reboot")
10999 );
11000 assert_eq!(
11001 preferred_host_inspection_topic("is there a pending restart waiting?"),
11002 Some("pending_reboot")
11003 );
11004 }
11005
11006 #[test]
11007 fn intent_router_picks_disk_health_for_drive_health_questions() {
11008 assert_eq!(
11009 preferred_host_inspection_topic("is my hard drive dying?"),
11010 Some("disk_health")
11011 );
11012 assert_eq!(
11013 preferred_host_inspection_topic("check the disk health and SMART status"),
11014 Some("disk_health")
11015 );
11016 assert_eq!(
11017 preferred_host_inspection_topic("is my SSD healthy?"),
11018 Some("disk_health")
11019 );
11020 }
11021
11022 #[test]
11023 fn intent_router_picks_battery_for_battery_questions() {
11024 assert_eq!(
11025 preferred_host_inspection_topic("check my battery"),
11026 Some("battery")
11027 );
11028 assert_eq!(
11029 preferred_host_inspection_topic("how is my battery life?"),
11030 Some("battery")
11031 );
11032 assert_eq!(
11033 preferred_host_inspection_topic("what is my battery wear level?"),
11034 Some("battery")
11035 );
11036 }
11037
11038 #[test]
11039 fn intent_router_picks_recent_crashes_for_bsod_questions() {
11040 assert_eq!(
11041 preferred_host_inspection_topic("why did my PC restart by itself?"),
11042 Some("recent_crashes")
11043 );
11044 assert_eq!(
11045 preferred_host_inspection_topic("did my computer BSOD recently?"),
11046 Some("recent_crashes")
11047 );
11048 assert_eq!(
11049 preferred_host_inspection_topic("show me any recent app crashes"),
11050 Some("recent_crashes")
11051 );
11052 }
11053
11054 #[test]
11055 fn intent_router_picks_scheduled_tasks_for_task_questions() {
11056 assert_eq!(
11057 preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
11058 Some("scheduled_tasks")
11059 );
11060 assert_eq!(
11061 preferred_host_inspection_topic("show me the task scheduler"),
11062 Some("scheduled_tasks")
11063 );
11064 }
11065
11066 #[test]
11067 fn intent_router_picks_dev_conflicts_for_conflict_questions() {
11068 assert_eq!(
11069 preferred_host_inspection_topic("are there any dev environment conflicts?"),
11070 Some("dev_conflicts")
11071 );
11072 assert_eq!(
11073 preferred_host_inspection_topic("why is python pointing to the wrong version?"),
11074 Some("dev_conflicts")
11075 );
11076 }
11077
11078 #[test]
11079 fn shell_guard_catches_windows_update_commands() {
11080 assert!(shell_looks_like_structured_host_inspection(
11081 "Get-WindowsUpdateLog | Select-Object -Last 50"
11082 ));
11083 assert!(shell_looks_like_structured_host_inspection(
11084 "$sess = New-Object -ComObject Microsoft.Update.Session"
11085 ));
11086 assert!(shell_looks_like_structured_host_inspection(
11087 "Get-Service wuauserv"
11088 ));
11089 assert!(shell_looks_like_structured_host_inspection(
11090 "Get-MpComputerStatus"
11091 ));
11092 assert!(shell_looks_like_structured_host_inspection(
11093 "Get-PhysicalDisk"
11094 ));
11095 assert!(shell_looks_like_structured_host_inspection(
11096 "Get-CimInstance Win32_Battery"
11097 ));
11098 assert!(shell_looks_like_structured_host_inspection(
11099 "Get-WinEvent -FilterHashtable @{Id=41}"
11100 ));
11101 assert!(shell_looks_like_structured_host_inspection(
11102 "Get-ScheduledTask | Where-Object State -ne Disabled"
11103 ));
11104 }
11105
11106 #[test]
11107 fn intent_router_picks_permissions_for_acl_questions() {
11108 assert_eq!(
11109 preferred_host_inspection_topic("who has permission to access the downloads folder?"),
11110 Some("permissions")
11111 );
11112 assert_eq!(
11113 preferred_host_inspection_topic("audit the ntfs permissions for this path"),
11114 Some("permissions")
11115 );
11116 }
11117
11118 #[test]
11119 fn intent_router_picks_login_history_for_logon_questions() {
11120 assert_eq!(
11121 preferred_host_inspection_topic("who logged in recently on this machine?"),
11122 Some("login_history")
11123 );
11124 assert_eq!(
11125 preferred_host_inspection_topic("show me the logon history for the last 48 hours"),
11126 Some("login_history")
11127 );
11128 }
11129
11130 #[test]
11131 fn intent_router_picks_share_access_for_unc_questions() {
11132 assert_eq!(
11133 preferred_host_inspection_topic("can i reach \\\\server\\share right now?"),
11134 Some("share_access")
11135 );
11136 assert_eq!(
11137 preferred_host_inspection_topic("test accessibility of a network share"),
11138 Some("share_access")
11139 );
11140 }
11141
11142 #[test]
11143 fn intent_router_picks_registry_audit_for_persistence_questions() {
11144 assert_eq!(
11145 preferred_host_inspection_topic(
11146 "audit my registry for persistence hacks or debugger hijacking"
11147 ),
11148 Some("registry_audit")
11149 );
11150 assert_eq!(
11151 preferred_host_inspection_topic("check winlogon shell integrity and ifeo hijacks"),
11152 Some("registry_audit")
11153 );
11154 }
11155
11156 #[test]
11157 fn intent_router_picks_network_stats_for_mbps_questions() {
11158 assert_eq!(
11159 preferred_host_inspection_topic("what is my network throughput in mbps right now?"),
11160 Some("network_stats")
11161 );
11162 }
11163
11164 #[test]
11165 fn intent_router_picks_processes_for_cpu_percentage_questions() {
11166 assert_eq!(
11167 preferred_host_inspection_topic("which processes are using the most cpu % right now?"),
11168 Some("processes")
11169 );
11170 }
11171
11172 #[test]
11173 fn intent_router_picks_log_check_for_recent_window_questions() {
11174 assert_eq!(
11175 preferred_host_inspection_topic("show me system errors from the last 2 hours"),
11176 Some("log_check")
11177 );
11178 }
11179
11180 #[test]
11181 fn intent_router_picks_battery_for_health_and_cycles() {
11182 assert_eq!(
11183 preferred_host_inspection_topic("check my battery health and cycle count"),
11184 Some("battery")
11185 );
11186 }
11187
11188 #[test]
11189 fn intent_router_picks_thermal_for_throttling_questions() {
11190 assert_eq!(
11191 preferred_host_inspection_topic(
11192 "why is my laptop slow? check for overheating or throttling"
11193 ),
11194 Some("thermal")
11195 );
11196 assert_eq!(
11197 preferred_host_inspection_topic("show me the current cpu temp"),
11198 Some("thermal")
11199 );
11200 }
11201
11202 #[test]
11203 fn intent_router_picks_activation_for_genuine_questions() {
11204 assert_eq!(
11205 preferred_host_inspection_topic("is my windows genuine? check activation status"),
11206 Some("activation")
11207 );
11208 assert_eq!(
11209 preferred_host_inspection_topic("run slmgr to check my license state"),
11210 Some("activation")
11211 );
11212 }
11213
11214 #[test]
11215 fn intent_router_picks_patch_history_for_hotfix_questions() {
11216 assert_eq!(
11217 preferred_host_inspection_topic("show me the recently installed hotfixes"),
11218 Some("patch_history")
11219 );
11220 assert_eq!(
11221 preferred_host_inspection_topic(
11222 "list the windows update patch history for the last 48 hours"
11223 ),
11224 Some("patch_history")
11225 );
11226 }
11227
11228 #[test]
11229 fn intent_router_detects_multiple_symptoms_for_prerun() {
11230 let topics = all_host_inspection_topics("Why is my laptop slow? Check if it is overheating, throttling, or under heavy I/O pressure.");
11231 assert!(topics.contains(&"thermal"));
11232 assert!(topics.contains(&"resource_load"));
11233 assert!(topics.contains(&"storage"));
11234 assert!(topics.len() >= 3);
11235 }
11236
11237 #[test]
11238 fn parse_unload_target_supports_current_and_all() {
11239 assert_eq!(
11240 ConversationManager::parse_unload_target("current").unwrap(),
11241 (None, false)
11242 );
11243 assert_eq!(
11244 ConversationManager::parse_unload_target("all").unwrap(),
11245 (None, true)
11246 );
11247 assert_eq!(
11248 ConversationManager::parse_unload_target("qwen/qwen3.5-9b").unwrap(),
11249 (Some("qwen/qwen3.5-9b".to_string()), false)
11250 );
11251 }
11252
11253 #[test]
11254 fn provider_model_controls_summary_mentions_ollama_limits() {
11255 let ollama = ConversationManager::provider_model_controls_summary("Ollama");
11256 assert!(ollama.contains("Ollama supports coding and embed model load/list/unload"));
11257 let lms = ConversationManager::provider_model_controls_summary("LM Studio");
11258 assert!(lms.contains("LM Studio supports coding and embed model load/unload"));
11259 }
11260}