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