1use crate::agent::architecture_summary::{
2 build_architecture_overview_answer, prune_architecture_trace_batch,
3 prune_authoritative_tool_batch, prune_read_only_context_bloat_batch,
4 prune_redirected_shell_batch, summarize_runtime_trace_output,
5};
6use crate::agent::direct_answers::{
7 build_about_answer, build_architect_session_reset_plan, build_authorization_policy_answer,
8 build_gemma_native_answer, build_gemma_native_settings_answer, build_identity_answer,
9 build_language_capability_answer, build_mcp_lifecycle_answer, build_product_surface_answer,
10 build_reasoning_split_answer, build_recovery_recipes_answer, build_session_memory_answer,
11 build_session_reset_semantics_answer, build_tool_classes_answer,
12 build_tool_registry_ownership_answer, build_unsafe_workflow_pressure_answer,
13 build_verify_profiles_answer, build_workflow_modes_answer,
14};
15use crate::agent::inference::{
16 ChatMessage, InferenceEngine, InferenceEvent, MessageContent, OperatorCheckpointState,
17 ProviderRuntimeState, ToolCallFn, ToolDefinition, ToolFunction,
18};
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, normalize_workspace_path,
22};
23use crate::agent::recovery_recipes::{
24 attempt_recovery, plan_recovery, preview_recovery_decision, RecoveryContext, RecoveryDecision,
25 RecoveryPlan, RecoveryScenario, RecoveryStep,
26};
27use crate::agent::routing::{
28 classify_query_intent, is_capability_probe_tool, looks_like_mutation_request,
29 needs_computation_sandbox, preferred_host_inspection_topic, preferred_maintainer_workflow,
30 preferred_workspace_workflow, DirectAnswerKind, QueryIntentClass,
31};
32use crate::agent::tool_registry::dispatch_builtin_tool;
33use crate::agent::compaction::{self, CompactionConfig};
35use crate::ui::gpu_monitor::GpuState;
36
37use serde_json::Value;
38use std::sync::Arc;
39use tokio::sync::{mpsc, Mutex};
40#[derive(Clone, Debug, Default)]
43pub struct UserTurn {
44 pub text: String,
45 pub attached_document: Option<AttachedDocument>,
46 pub attached_image: Option<AttachedImage>,
47}
48
49#[derive(Clone, Debug)]
50pub struct AttachedDocument {
51 pub name: String,
52 pub content: String,
53}
54
55#[derive(Clone, Debug)]
56pub struct AttachedImage {
57 pub name: String,
58 pub path: String,
59}
60
61impl UserTurn {
62 pub fn text(text: impl Into<String>) -> Self {
63 Self {
64 text: text.into(),
65 attached_document: None,
66 attached_image: None,
67 }
68 }
69}
70
71#[derive(serde::Serialize, serde::Deserialize)]
72struct SavedSession {
73 running_summary: Option<String>,
74 #[serde(default)]
75 session_memory: crate::agent::compaction::SessionMemory,
76 #[serde(default)]
78 last_goal: Option<String>,
79 #[serde(default)]
81 turn_count: u32,
82}
83
84pub struct CheckpointResume {
87 pub last_goal: String,
88 pub turn_count: u32,
89 pub working_files: Vec<String>,
90 pub last_verify_ok: Option<bool>,
91}
92
93pub fn load_checkpoint() -> Option<CheckpointResume> {
96 let path = session_path();
97 let data = std::fs::read_to_string(&path).ok()?;
98 let saved: SavedSession = serde_json::from_str(&data).ok()?;
99 let goal = saved.last_goal.filter(|g| !g.trim().is_empty())?;
100 if saved.turn_count == 0 {
101 return None;
102 }
103 let mut working_files: Vec<String> = saved
104 .session_memory
105 .working_set
106 .into_iter()
107 .take(4)
108 .collect();
109 working_files.sort();
110 let last_verify_ok = saved.session_memory.last_verification.map(|v| v.successful);
111 Some(CheckpointResume {
112 last_goal: goal,
113 turn_count: saved.turn_count,
114 working_files,
115 last_verify_ok,
116 })
117}
118
119#[derive(Default)]
120struct ActionGroundingState {
121 turn_index: u64,
122 observed_paths: std::collections::HashMap<String, u64>,
123 inspected_paths: std::collections::HashMap<String, u64>,
124 last_verify_build_turn: Option<u64>,
125 last_verify_build_ok: bool,
126 last_failed_build_paths: Vec<String>,
127 code_changed_since_verify: bool,
128 redirected_host_inspection_topics: std::collections::HashMap<String, u64>,
130}
131
132struct PlanExecutionGuard {
133 flag: Arc<std::sync::atomic::AtomicBool>,
134}
135
136impl Drop for PlanExecutionGuard {
137 fn drop(&mut self) {
138 self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub(crate) enum WorkflowMode {
144 #[default]
145 Auto,
146 Ask,
147 Code,
148 Architect,
149 ReadOnly,
150 Chat,
153 Teach,
157}
158
159impl WorkflowMode {
160 fn label(self) -> &'static str {
161 match self {
162 WorkflowMode::Auto => "AUTO",
163 WorkflowMode::Ask => "ASK",
164 WorkflowMode::Code => "CODE",
165 WorkflowMode::Architect => "ARCHITECT",
166 WorkflowMode::ReadOnly => "READ-ONLY",
167 WorkflowMode::Chat => "CHAT",
168 WorkflowMode::Teach => "TEACH",
169 }
170 }
171
172 fn is_read_only(self) -> bool {
173 matches!(
174 self,
175 WorkflowMode::Ask
176 | WorkflowMode::Architect
177 | WorkflowMode::ReadOnly
178 | WorkflowMode::Teach
179 )
180 }
181
182 pub(crate) fn is_chat(self) -> bool {
183 matches!(self, WorkflowMode::Chat)
184 }
185}
186
187fn session_path() -> std::path::PathBuf {
188 if let Ok(overridden) = std::env::var("HEMATITE_SESSION_PATH") {
189 return std::path::PathBuf::from(overridden);
190 }
191 crate::tools::file_ops::workspace_root()
192 .join(".hematite")
193 .join("session.json")
194}
195
196fn load_session_data() -> (Option<String>, crate::agent::compaction::SessionMemory) {
197 let path = session_path();
198 if !path.exists() {
199 return (None, crate::agent::compaction::SessionMemory::default());
200 }
201 let Ok(data) = std::fs::read_to_string(&path) else {
202 return (None, crate::agent::compaction::SessionMemory::default());
203 };
204 let Ok(saved) = serde_json::from_str::<SavedSession>(&data) else {
205 return (None, crate::agent::compaction::SessionMemory::default());
206 };
207 (saved.running_summary, saved.session_memory)
208}
209
210fn reset_task_files() {
211 let root = crate::tools::file_ops::workspace_root();
212 let _ = std::fs::remove_file(root.join(".hematite").join("TASK.md"));
213 let _ = std::fs::remove_file(root.join(".hematite").join("PLAN.md"));
214 let _ = std::fs::remove_file(root.join(".hematite").join("WALKTHROUGH.md"));
215 let _ = std::fs::remove_file(root.join(".github").join("WALKTHROUGH.md"));
216 let _ = std::fs::write(root.join(".hematite").join("TASK.md"), "");
217 let _ = std::fs::write(root.join(".hematite").join("PLAN.md"), "");
218}
219
220fn purge_persistent_memory() {
221 let root = crate::tools::file_ops::workspace_root();
222 let mem_dir = root.join(".hematite").join("memories");
223 if mem_dir.exists() {
224 let _ = std::fs::remove_dir_all(&mem_dir);
225 let _ = std::fs::create_dir_all(&mem_dir);
226 }
227
228 let log_dir = root.join(".hematite_logs");
229 if log_dir.exists() {
230 if let Ok(entries) = std::fs::read_dir(&log_dir) {
231 for entry in entries.flatten() {
232 let _ = std::fs::write(entry.path(), "");
233 }
234 }
235 }
236}
237
238fn apply_turn_attachments(user_turn: &UserTurn, prompt: &str) -> String {
239 let mut out = prompt.trim().to_string();
240 if let Some(doc) = user_turn.attached_document.as_ref() {
241 out = format!(
242 "[Attached document: {}]\n\n{}\n\n---\n\n{}",
243 doc.name, doc.content, out
244 );
245 }
246 if let Some(image) = user_turn.attached_image.as_ref() {
247 out = if out.is_empty() {
248 format!("[Attached image: {}]", image.name)
249 } else {
250 format!("[Attached image: {}]\n\n{}", image.name, out)
251 };
252 }
253 out
254}
255
256fn transcript_user_turn_text(user_turn: &UserTurn, prompt: &str) -> String {
257 let mut prefixes = Vec::new();
258 if let Some(doc) = user_turn.attached_document.as_ref() {
259 prefixes.push(format!("[Attached document: {}]", doc.name));
260 }
261 if let Some(image) = user_turn.attached_image.as_ref() {
262 prefixes.push(format!("[Attached image: {}]", image.name));
263 }
264 if prefixes.is_empty() {
265 prompt.to_string()
266 } else if prompt.trim().is_empty() {
267 prefixes.join("\n")
268 } else {
269 format!("{}\n{}", prefixes.join("\n"), prompt)
270 }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274enum RuntimeFailureClass {
275 ContextWindow,
276 ProviderDegraded,
277 ToolArgMalformed,
278 ToolPolicyBlocked,
279 ToolLoop,
280 VerificationFailed,
281 EmptyModelResponse,
282 Unknown,
283}
284
285impl RuntimeFailureClass {
286 fn tag(self) -> &'static str {
287 match self {
288 RuntimeFailureClass::ContextWindow => "context_window",
289 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
290 RuntimeFailureClass::ToolArgMalformed => "tool_arg_malformed",
291 RuntimeFailureClass::ToolPolicyBlocked => "tool_policy_blocked",
292 RuntimeFailureClass::ToolLoop => "tool_loop",
293 RuntimeFailureClass::VerificationFailed => "verification_failed",
294 RuntimeFailureClass::EmptyModelResponse => "empty_model_response",
295 RuntimeFailureClass::Unknown => "unknown",
296 }
297 }
298
299 fn operator_guidance(self) -> &'static str {
300 match self {
301 RuntimeFailureClass::ContextWindow => {
302 "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."
303 }
304 RuntimeFailureClass::ProviderDegraded => {
305 "Retry once automatically, then narrow the turn or restart LM Studio if it persists."
306 }
307 RuntimeFailureClass::ToolArgMalformed => {
308 "Retry with repaired or narrower tool arguments instead of repeating the same malformed call."
309 }
310 RuntimeFailureClass::ToolPolicyBlocked => {
311 "Stay inside the allowed workflow or switch modes before retrying."
312 }
313 RuntimeFailureClass::ToolLoop => {
314 "Stop repeating the same failing tool pattern and switch to a narrower recovery step."
315 }
316 RuntimeFailureClass::VerificationFailed => {
317 "Fix the build or test failure before treating the task as complete."
318 }
319 RuntimeFailureClass::EmptyModelResponse => {
320 "Retry once automatically, then narrow the turn or restart LM Studio if the model keeps returning nothing."
321 }
322 RuntimeFailureClass::Unknown => {
323 "Inspect the latest grounded tool results or provider status before retrying."
324 }
325 }
326 }
327}
328
329fn classify_runtime_failure(detail: &str) -> RuntimeFailureClass {
330 let lower = detail.to_ascii_lowercase();
331 if lower.contains("context_window_blocked")
332 || lower.contains("context ceiling reached")
333 || lower.contains("exceeds the")
334 || ((lower.contains("n_keep") && lower.contains("n_ctx"))
335 || lower.contains("context length")
336 || lower.contains("keep from the initial prompt")
337 || lower.contains("prompt is greater than the context length"))
338 {
339 RuntimeFailureClass::ContextWindow
340 } else if lower.contains("empty response from model")
341 || lower.contains("model returned an empty response")
342 {
343 RuntimeFailureClass::EmptyModelResponse
344 } else if lower.contains("lm studio unreachable")
345 || lower.contains("lm studio error")
346 || lower.contains("request failed")
347 || lower.contains("response parse error")
348 || lower.contains("provider degraded")
349 {
350 RuntimeFailureClass::ProviderDegraded
351 } else if lower.contains("missing required argument")
352 || lower.contains("json repair failed")
353 || lower.contains("invalid pattern")
354 || lower.contains("invalid line range")
355 {
356 RuntimeFailureClass::ToolArgMalformed
357 } else if lower.contains("action blocked:")
358 || lower.contains("access denied")
359 || lower.contains("declined by user")
360 {
361 RuntimeFailureClass::ToolPolicyBlocked
362 } else if lower.contains("too many consecutive tool errors")
363 || lower.contains("repeated tool failures")
364 || lower.contains("stuck in a loop")
365 {
366 RuntimeFailureClass::ToolLoop
367 } else if lower.contains("build failed")
368 || lower.contains("verification failed")
369 || lower.contains("verify_build")
370 {
371 RuntimeFailureClass::VerificationFailed
372 } else {
373 RuntimeFailureClass::Unknown
374 }
375}
376
377fn format_runtime_failure(class: RuntimeFailureClass, detail: &str) -> String {
378 format!(
379 "[failure:{}] {} Detail: {}",
380 class.tag(),
381 class.operator_guidance(),
382 detail.trim()
383 )
384}
385
386fn provider_state_for_runtime_failure(class: RuntimeFailureClass) -> Option<ProviderRuntimeState> {
387 match class {
388 RuntimeFailureClass::ContextWindow => Some(ProviderRuntimeState::ContextWindow),
389 RuntimeFailureClass::ProviderDegraded => Some(ProviderRuntimeState::Degraded),
390 RuntimeFailureClass::EmptyModelResponse => Some(ProviderRuntimeState::EmptyResponse),
391 _ => None,
392 }
393}
394
395fn checkpoint_state_for_runtime_failure(
396 class: RuntimeFailureClass,
397) -> Option<OperatorCheckpointState> {
398 match class {
399 RuntimeFailureClass::ContextWindow => Some(OperatorCheckpointState::BlockedContextWindow),
400 RuntimeFailureClass::ToolPolicyBlocked => Some(OperatorCheckpointState::BlockedPolicy),
401 RuntimeFailureClass::ToolLoop => Some(OperatorCheckpointState::BlockedToolLoop),
402 RuntimeFailureClass::VerificationFailed => {
403 Some(OperatorCheckpointState::BlockedVerification)
404 }
405 _ => None,
406 }
407}
408
409fn compact_runtime_recovery_summary(class: RuntimeFailureClass) -> &'static str {
410 match class {
411 RuntimeFailureClass::ProviderDegraded => {
412 "LM Studio degraded during the turn; retrying once before surfacing a failure."
413 }
414 RuntimeFailureClass::EmptyModelResponse => {
415 "The model returned an empty reply; retrying once before surfacing a failure."
416 }
417 _ => "Runtime recovery in progress.",
418 }
419}
420
421fn checkpoint_summary_for_runtime_failure(class: RuntimeFailureClass) -> &'static str {
422 match class {
423 RuntimeFailureClass::ContextWindow => "Provider context ceiling confirmed.",
424 RuntimeFailureClass::ToolPolicyBlocked => "Policy blocked the current action.",
425 RuntimeFailureClass::ToolLoop => "Repeated failing tool pattern stopped.",
426 RuntimeFailureClass::VerificationFailed => "Verification failed; fix before continuing.",
427 _ => "Operator checkpoint updated.",
428 }
429}
430
431fn compact_runtime_failure_summary(class: RuntimeFailureClass) -> &'static str {
432 match class {
433 RuntimeFailureClass::ContextWindow => "LM context ceiling hit.",
434 RuntimeFailureClass::ProviderDegraded => {
435 "LM Studio degraded and did not recover cleanly; operator action is now required."
436 }
437 RuntimeFailureClass::EmptyModelResponse => {
438 "LM Studio returned an empty reply after recovery; operator action is now required."
439 }
440 RuntimeFailureClass::ToolLoop => {
441 "Repeated failing tool pattern detected; Hematite stopped the loop."
442 }
443 _ => "Runtime failure surfaced to the operator.",
444 }
445}
446
447fn should_retry_runtime_failure(class: RuntimeFailureClass) -> bool {
448 matches!(
449 class,
450 RuntimeFailureClass::ProviderDegraded | RuntimeFailureClass::EmptyModelResponse
451 )
452}
453
454fn recovery_scenario_for_runtime_failure(class: RuntimeFailureClass) -> Option<RecoveryScenario> {
455 match class {
456 RuntimeFailureClass::ContextWindow => Some(RecoveryScenario::ContextWindow),
457 RuntimeFailureClass::ProviderDegraded => Some(RecoveryScenario::ProviderDegraded),
458 RuntimeFailureClass::EmptyModelResponse => Some(RecoveryScenario::EmptyModelResponse),
459 RuntimeFailureClass::ToolPolicyBlocked => Some(RecoveryScenario::McpWorkspaceReadBlocked),
460 RuntimeFailureClass::ToolLoop => Some(RecoveryScenario::ToolLoop),
461 RuntimeFailureClass::VerificationFailed => Some(RecoveryScenario::VerificationFailed),
462 RuntimeFailureClass::ToolArgMalformed | RuntimeFailureClass::Unknown => None,
463 }
464}
465
466fn compact_recovery_plan_summary(plan: &RecoveryPlan) -> String {
467 format!(
468 "{} [{}]",
469 plan.recipe.scenario.label(),
470 plan.recipe.steps_summary()
471 )
472}
473
474fn compact_recovery_decision_summary(decision: &RecoveryDecision) -> String {
475 match decision {
476 RecoveryDecision::Attempt(plan) => compact_recovery_plan_summary(plan),
477 RecoveryDecision::Escalate {
478 recipe,
479 attempts_made,
480 ..
481 } => format!(
482 "{} escalated after {} / {} [{}]",
483 recipe.scenario.label(),
484 attempts_made,
485 recipe.max_attempts.max(1),
486 recipe.steps_summary()
487 ),
488 }
489}
490
491fn parse_failing_paths_from_build_output(output: &str) -> Vec<String> {
494 let root = crate::tools::file_ops::workspace_root();
495 let mut paths: Vec<String> = output
496 .lines()
497 .filter_map(|line| {
498 let trimmed = line.trim_start();
499 let after_arrow = trimmed.strip_prefix("--> ")?;
501 let file_part = after_arrow.split(':').next()?;
502 if file_part.is_empty() || file_part.starts_with('<') {
503 return None;
504 }
505 let p = std::path::Path::new(file_part);
506 let resolved = if p.is_absolute() {
507 p.to_path_buf()
508 } else {
509 root.join(p)
510 };
511 Some(resolved.to_string_lossy().replace('\\', "/").to_lowercase())
512 })
513 .collect();
514 paths.sort();
515 paths.dedup();
516 paths
517}
518
519fn build_mode_redirect_answer(mode: WorkflowMode) -> String {
520 match mode {
521 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(),
522 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(),
523 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(),
524 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(),
525 _ => "Switch to `/code` or `/auto` to allow implementation.".to_string(),
526 }
527}
528
529fn architect_handoff_contract() -> &'static str {
530 "ARCHITECT OUTPUT CONTRACT:\n\
531Use a compact implementation handoff, not a process narrative.\n\
532Do not say \"the first step\" or describe what you are about to do.\n\
533After one or two read-only inspection tools at most, stop and answer.\n\
534For runtime wiring, reset behavior, or control-flow questions, prefer `trace_runtime_flow`.\n\
535Use these exact ASCII headings and keep each section short:\n\
536# Goal\n\
537# Target Files\n\
538# Ordered Steps\n\
539# Verification\n\
540# Risks\n\
541# Open Questions\n\
542Keep the whole handoff concise and implementation-oriented."
543}
544
545fn implement_current_plan_prompt() -> &'static str {
546 "Implement the current plan."
547}
548
549fn architect_handoff_operator_note(plan: &crate::tools::plan::PlanHandoff) -> String {
550 format!(
551 "Implementation handoff saved to `.hematite/PLAN.md`.\nNext step: run `/implement-plan` to execute it in `/code`, or use `/code {}` directly.\nPlan: {}",
552 implement_current_plan_prompt().to_ascii_lowercase(),
553 plan.summary_line()
554 )
555}
556
557fn is_current_plan_execution_request(user_input: &str) -> bool {
558 let lower = user_input.trim().to_ascii_lowercase();
559 lower == "/implement-plan"
560 || lower == implement_current_plan_prompt().to_ascii_lowercase()
561 || lower
562 == implement_current_plan_prompt()
563 .trim_end_matches('.')
564 .to_ascii_lowercase()
565 || lower.contains("implement the current plan")
566}
567
568fn is_plan_scoped_tool(name: &str) -> bool {
569 crate::agent::inference::tool_metadata_for_name(name).plan_scope
570}
571
572fn is_current_plan_irrelevant_tool(name: &str) -> bool {
573 !crate::agent::inference::tool_metadata_for_name(name).plan_scope
574}
575
576fn is_non_mutating_plan_step_tool(name: &str) -> bool {
577 let metadata = crate::agent::inference::tool_metadata_for_name(name);
578 metadata.plan_scope && !metadata.mutates_workspace
579}
580
581fn parse_inline_workflow_prompt(user_input: &str) -> Option<(WorkflowMode, &str)> {
582 let trimmed = user_input.trim();
583 for (prefix, mode) in [
584 ("/ask", WorkflowMode::Ask),
585 ("/code", WorkflowMode::Code),
586 ("/architect", WorkflowMode::Architect),
587 ("/read-only", WorkflowMode::ReadOnly),
588 ("/auto", WorkflowMode::Auto),
589 ("/teach", WorkflowMode::Teach),
590 ] {
591 if let Some(rest) = trimmed.strip_prefix(prefix) {
592 let rest = rest.trim();
593 if !rest.is_empty() {
594 return Some((mode, rest));
595 }
596 }
597 }
598 None
599}
600
601pub fn get_tools() -> Vec<ToolDefinition> {
605 crate::agent::tool_registry::get_tools()
606}
607
608pub struct ConversationManager {
609 pub history: Vec<ChatMessage>,
611 pub engine: Arc<InferenceEngine>,
612 pub tools: Vec<ToolDefinition>,
613 pub mcp_manager: Arc<Mutex<crate::agent::mcp_manager::McpManager>>,
614 pub professional: bool,
615 pub brief: bool,
616 pub snark: u8,
617 pub chaos: u8,
618 pub fast_model: Option<String>,
620 pub think_model: Option<String>,
622 pub correction_hints: Vec<String>,
624 pub running_summary: Option<String>,
626 pub gpu_state: Arc<GpuState>,
628 pub vein: crate::memory::vein::Vein,
630 pub transcript: crate::agent::transcript::TranscriptLogger,
632 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
634 pub git_state: Arc<crate::agent::git_monitor::GitState>,
636 pub think_mode: Option<bool>,
639 workflow_mode: WorkflowMode,
640 pub session_memory: crate::agent::compaction::SessionMemory,
642 pub swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
643 pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
644 pub soul_personality: String,
646 pub lsp_manager: Arc<Mutex<crate::agent::lsp::manager::LspManager>>,
647 pub reasoning_history: Option<String>,
649 pub pinned_files: Arc<Mutex<std::collections::HashMap<String, String>>>,
651 action_grounding: Arc<Mutex<ActionGroundingState>>,
653 plan_execution_active: Arc<std::sync::atomic::AtomicBool>,
655 recovery_context: RecoveryContext,
657 pub l1_context: Option<String>,
660 pub repo_map: Option<String>,
662 pub turn_count: u32,
664 pub last_goal: Option<String>,
666}
667
668impl ConversationManager {
669 fn vein_docs_only_mode(&self) -> bool {
670 !crate::tools::file_ops::is_project_workspace()
671 }
672
673 fn refresh_vein_index(&mut self) -> usize {
674 let count = if self.vein_docs_only_mode() {
675 let root = crate::tools::file_ops::workspace_root();
676 tokio::task::block_in_place(|| self.vein.index_workspace_artifacts(&root))
677 } else {
678 tokio::task::block_in_place(|| self.vein.index_project())
679 };
680 self.l1_context = self.vein.l1_context();
681 count
682 }
683
684 fn build_vein_inspection_report(&self, indexed_this_pass: usize) -> String {
685 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(8));
686 let workspace_mode = if self.vein_docs_only_mode() {
687 "docs-only (outside a project workspace)"
688 } else {
689 "project workspace"
690 };
691 let active_room = snapshot.active_room.as_deref().unwrap_or("none");
692 let mut out = format!(
693 "Vein Inspection\n\
694 Workspace mode: {workspace_mode}\n\
695 Indexed this pass: {indexed_this_pass}\n\
696 Indexed source files: {}\n\
697 Indexed docs: {}\n\
698 Indexed session exchanges: {}\n\
699 Embedded source/doc chunks: {}\n\
700 Embeddings available: {}\n\
701 Active room bias: {active_room}\n\
702 L1 hot-files block: {}\n",
703 snapshot.indexed_source_files,
704 snapshot.indexed_docs,
705 snapshot.indexed_session_exchanges,
706 snapshot.embedded_source_doc_chunks,
707 if snapshot.has_any_embeddings {
708 "yes"
709 } else {
710 "no"
711 },
712 if snapshot.l1_ready {
713 "ready"
714 } else {
715 "not built yet"
716 },
717 );
718
719 if snapshot.hot_files.is_empty() {
720 out.push_str("Hot files: none yet.\n");
721 return out;
722 }
723
724 out.push_str("\nHot files by room:\n");
725 let mut by_room: std::collections::BTreeMap<&str, Vec<&crate::memory::vein::VeinHotFile>> =
726 std::collections::BTreeMap::new();
727 for file in &snapshot.hot_files {
728 by_room.entry(file.room.as_str()).or_default().push(file);
729 }
730 for (room, files) in by_room {
731 out.push_str(&format!("[{}]\n", room));
732 for file in files {
733 out.push_str(&format!(
734 "- {} [{} edit{}]\n",
735 file.path,
736 file.heat,
737 if file.heat == 1 { "" } else { "s" }
738 ));
739 }
740 }
741
742 out
743 }
744
745 fn latest_user_prompt(&self) -> Option<&str> {
746 self.history
747 .iter()
748 .rev()
749 .find(|msg| msg.role == "user")
750 .map(|msg| msg.content.as_str())
751 }
752
753 async fn emit_direct_response(
754 &mut self,
755 tx: &mpsc::Sender<InferenceEvent>,
756 raw_user_input: &str,
757 effective_user_input: &str,
758 response: &str,
759 ) {
760 self.history.push(ChatMessage::user(effective_user_input));
761 self.history.push(ChatMessage::assistant_text(response));
762 self.transcript.log_user(raw_user_input);
763 self.transcript.log_agent(response);
764 for chunk in chunk_text(response, 8) {
765 if !chunk.is_empty() {
766 let _ = tx.send(InferenceEvent::Token(chunk)).await;
767 }
768 }
769 let _ = tx.send(InferenceEvent::Done).await;
770 self.trim_history(80);
771 self.refresh_session_memory();
772 self.save_session();
773 }
774
775 async fn emit_operator_checkpoint(
776 &mut self,
777 tx: &mpsc::Sender<InferenceEvent>,
778 state: OperatorCheckpointState,
779 summary: impl Into<String>,
780 ) {
781 let summary = summary.into();
782 self.session_memory
783 .record_checkpoint(state.label(), summary.clone());
784 let _ = tx
785 .send(InferenceEvent::OperatorCheckpoint { state, summary })
786 .await;
787 }
788
789 async fn emit_recovery_recipe_summary(
790 &mut self,
791 tx: &mpsc::Sender<InferenceEvent>,
792 state: impl Into<String>,
793 summary: impl Into<String>,
794 ) {
795 let state = state.into();
796 let summary = summary.into();
797 self.session_memory.record_recovery(state, summary.clone());
798 let _ = tx.send(InferenceEvent::RecoveryRecipe { summary }).await;
799 }
800
801 async fn emit_provider_live(&mut self, tx: &mpsc::Sender<InferenceEvent>) {
802 let _ = tx
803 .send(InferenceEvent::ProviderStatus {
804 state: ProviderRuntimeState::Live,
805 summary: String::new(),
806 })
807 .await;
808 self.emit_operator_checkpoint(tx, OperatorCheckpointState::Idle, "")
809 .await;
810 }
811
812 async fn emit_prompt_pressure_for_messages(
813 &self,
814 tx: &mpsc::Sender<InferenceEvent>,
815 messages: &[ChatMessage],
816 ) {
817 let context_length = self.engine.current_context_length();
818 let (estimated_input_tokens, reserved_output_tokens, estimated_total_tokens, percent) =
819 crate::agent::inference::estimate_prompt_pressure(
820 messages,
821 &self.tools,
822 context_length,
823 );
824 let _ = tx
825 .send(InferenceEvent::PromptPressure {
826 estimated_input_tokens,
827 reserved_output_tokens,
828 estimated_total_tokens,
829 context_length,
830 percent,
831 })
832 .await;
833 }
834
835 async fn emit_prompt_pressure_idle(&self, tx: &mpsc::Sender<InferenceEvent>) {
836 let context_length = self.engine.current_context_length();
837 let _ = tx
838 .send(InferenceEvent::PromptPressure {
839 estimated_input_tokens: 0,
840 reserved_output_tokens: 0,
841 estimated_total_tokens: 0,
842 context_length,
843 percent: 0,
844 })
845 .await;
846 }
847
848 async fn emit_compaction_pressure(&self, tx: &mpsc::Sender<InferenceEvent>) {
849 let context_length = self.engine.current_context_length();
850 let vram_ratio = self.gpu_state.ratio();
851 let config = CompactionConfig::adaptive(context_length, vram_ratio);
852 let estimated_tokens = compaction::estimate_compactable_tokens(&self.history);
853 let percent = if config.max_estimated_tokens == 0 {
854 0
855 } else {
856 ((estimated_tokens.saturating_mul(100)) / config.max_estimated_tokens).min(100) as u8
857 };
858
859 let _ = tx
860 .send(InferenceEvent::CompactionPressure {
861 estimated_tokens,
862 threshold_tokens: config.max_estimated_tokens,
863 percent,
864 })
865 .await;
866 }
867
868 async fn refresh_runtime_profile_and_report(
869 &mut self,
870 tx: &mpsc::Sender<InferenceEvent>,
871 reason: &str,
872 ) -> Option<(String, usize, bool)> {
873 let refreshed = self.engine.refresh_runtime_profile().await;
874 if let Some((model_id, context_length, changed)) = refreshed.as_ref() {
875 let _ = tx
876 .send(InferenceEvent::RuntimeProfile {
877 model_id: model_id.clone(),
878 context_length: *context_length,
879 })
880 .await;
881 self.transcript.log_system(&format!(
882 "Runtime profile refresh ({}): model={} ctx={} changed={}",
883 reason, model_id, context_length, changed
884 ));
885 }
886 refreshed
887 }
888
889 pub fn new(
890 engine: Arc<InferenceEngine>,
891 professional: bool,
892 brief: bool,
893 snark: u8,
894 chaos: u8,
895 soul_personality: String,
896 fast_model: Option<String>,
897 think_model: Option<String>,
898 gpu_state: Arc<GpuState>,
899 git_state: Arc<crate::agent::git_monitor::GitState>,
900 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
901 voice_manager: Arc<crate::ui::voice::VoiceManager>,
902 ) -> Self {
903 let (saved_summary, saved_memory) = load_session_data();
904
905 let mcp_manager = Arc::new(tokio::sync::Mutex::new(
907 crate::agent::mcp_manager::McpManager::new(),
908 ));
909
910 let dynamic_instructions =
912 engine.build_system_prompt(snark, chaos, brief, professional, &[], None, &[]);
913
914 let history = vec![ChatMessage::system(&dynamic_instructions)];
915
916 let vein_path = crate::tools::file_ops::workspace_root()
917 .join(".hematite")
918 .join("vein.db");
919 let vein_base_url = engine.base_url.clone();
920 let vein = crate::memory::vein::Vein::new(&vein_path, vein_base_url.clone())
921 .unwrap_or_else(|_| crate::memory::vein::Vein::new(":memory:", vein_base_url).unwrap());
922
923 Self {
924 history,
925 engine,
926 tools: get_tools(),
927 mcp_manager,
928 professional,
929 brief,
930 snark,
931 chaos,
932 fast_model,
933 think_model,
934 correction_hints: Vec::new(),
935 running_summary: saved_summary,
936 gpu_state,
937 vein,
938 transcript: crate::agent::transcript::TranscriptLogger::new(),
939 cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
940 git_state,
941 think_mode: None,
942 workflow_mode: WorkflowMode::Auto,
943 session_memory: saved_memory,
944 swarm_coordinator,
945 voice_manager,
946 soul_personality,
947 lsp_manager: Arc::new(Mutex::new(crate::agent::lsp::manager::LspManager::new(
948 crate::tools::file_ops::workspace_root(),
949 ))),
950 reasoning_history: None,
951 pinned_files: Arc::new(Mutex::new(std::collections::HashMap::new())),
952 action_grounding: Arc::new(Mutex::new(ActionGroundingState::default())),
953 plan_execution_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
954 recovery_context: RecoveryContext::default(),
955 l1_context: None,
956 repo_map: None,
957 turn_count: 0,
958 last_goal: None,
959 }
960 }
961
962 pub fn initialize_vein(&mut self) -> usize {
965 self.refresh_vein_index()
966 }
967
968 pub fn initialize_repo_map(&mut self) {
970 if !self.vein_docs_only_mode() {
971 let root = crate::tools::file_ops::workspace_root();
972 let hot = self.vein.hot_files_weighted(10);
973 let gen = crate::memory::repo_map::RepoMapGenerator::new(&root).with_hot_files(&hot);
974 match tokio::task::block_in_place(|| gen.generate()) {
975 Ok(map) => self.repo_map = Some(map),
976 Err(e) => {
977 self.repo_map = Some(format!("Repo Map generation failed: {}", e));
978 }
979 }
980 }
981 }
982
983 fn refresh_repo_map(&mut self) {
986 self.initialize_repo_map();
987 }
988
989 fn save_session(&self) {
990 let path = session_path();
991 if let Some(parent) = path.parent() {
992 let _ = std::fs::create_dir_all(parent);
993 }
994 let saved = SavedSession {
995 running_summary: self.running_summary.clone(),
996 session_memory: self.session_memory.clone(),
997 last_goal: self.last_goal.clone(),
998 turn_count: self.turn_count,
999 };
1000 if let Ok(json) = serde_json::to_string(&saved) {
1001 let _ = std::fs::write(&path, json);
1002 }
1003 }
1004
1005 fn save_empty_session(&self) {
1006 let path = session_path();
1007 if let Some(parent) = path.parent() {
1008 let _ = std::fs::create_dir_all(parent);
1009 }
1010 let saved = SavedSession {
1011 running_summary: None,
1012 session_memory: crate::agent::compaction::SessionMemory::default(),
1013 last_goal: None,
1014 turn_count: 0,
1015 };
1016 if let Ok(json) = serde_json::to_string(&saved) {
1017 let _ = std::fs::write(&path, json);
1018 }
1019 }
1020
1021 fn refresh_session_memory(&mut self) {
1022 let current_plan = self.session_memory.current_plan.clone();
1023 let previous_memory = self.session_memory.clone();
1024 self.session_memory = compaction::extract_memory(&self.history);
1025 self.session_memory.current_plan = current_plan;
1026 self.session_memory
1027 .inherit_runtime_ledger_from(&previous_memory);
1028 }
1029
1030 fn build_chat_system_prompt(&self) -> String {
1031 let species = &self.engine.species;
1032 let personality = &self.soul_personality;
1033 format!(
1034 "You are {species}, a local AI companion running entirely on the user's GPU — no cloud, no subscriptions, no phoning home.\n\
1035 {personality}\n\n\
1036 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\
1037 Rules:\n\
1038 - Talk like a person. Skip the bullet-point breakdowns unless the topic genuinely needs structure.\n\
1039 - Answer directly. One paragraph is usually right.\n\
1040 - Don't call tools unless the user explicitly asks you to look at a file or run something.\n\
1041 - Don't narrate your reasoning or mention tool names unprompted.\n\
1042 - You can discuss code, debug ideas, explain concepts, help plan, or just talk.\n\
1043 - If the user clearly wants you to edit or build something, do it — but lead with conversation, not scaffolding.\n\
1044 - If the user wants the full coding harness, they can type `/agent`.\n",
1045 )
1046 }
1047
1048 fn append_session_handoff(&self, system_msg: &mut String) {
1049 let has_summary = self
1050 .running_summary
1051 .as_ref()
1052 .map(|s| !s.trim().is_empty())
1053 .unwrap_or(false);
1054 let has_memory = self.session_memory.has_signal();
1055
1056 if !has_summary && !has_memory {
1057 return;
1058 }
1059
1060 system_msg.push_str(
1061 "\n\n# LIGHTWEIGHT SESSION HANDOFF\n\
1062 This is compact carry-over from earlier work on this machine.\n\
1063 Use it only when it helps the current request.\n\
1064 Prefer current repository state, pinned files, and fresh tool results over stale session memory.\n",
1065 );
1066
1067 if has_memory {
1068 system_msg.push_str("\n## Active Task Memory\n");
1069 system_msg.push_str(&self.session_memory.to_prompt());
1070 }
1071
1072 if let Some(summary) = self.running_summary.as_deref() {
1073 if !summary.trim().is_empty() {
1074 system_msg.push_str("\n## Compacted Session Summary\n");
1075 system_msg.push_str(summary);
1076 system_msg.push('\n');
1077 }
1078 }
1079 }
1080
1081 fn set_workflow_mode(&mut self, mode: WorkflowMode) {
1082 self.workflow_mode = mode;
1083 }
1084
1085 fn current_plan_summary(&self) -> Option<String> {
1086 self.session_memory
1087 .current_plan
1088 .as_ref()
1089 .filter(|plan| plan.has_signal())
1090 .map(|plan| plan.summary_line())
1091 }
1092
1093 fn current_plan_allowed_paths(&self) -> Vec<String> {
1094 self.session_memory
1095 .current_plan
1096 .as_ref()
1097 .map(|plan| {
1098 plan.target_files
1099 .iter()
1100 .map(|path| normalize_workspace_path(path))
1101 .collect()
1102 })
1103 .unwrap_or_default()
1104 }
1105
1106 fn persist_architect_handoff(
1107 &mut self,
1108 response: &str,
1109 ) -> Option<crate::tools::plan::PlanHandoff> {
1110 if self.workflow_mode != WorkflowMode::Architect {
1111 return None;
1112 }
1113 let Some(plan) = crate::tools::plan::parse_plan_handoff(response) else {
1114 return None;
1115 };
1116 let _ = crate::tools::plan::save_plan_handoff(&plan);
1117 self.session_memory.current_plan = Some(plan.clone());
1118 Some(plan)
1119 }
1120
1121 async fn begin_grounded_turn(&self) -> u64 {
1122 let mut state = self.action_grounding.lock().await;
1123 state.turn_index += 1;
1124 state.turn_index
1125 }
1126
1127 async fn reset_action_grounding(&self) {
1128 let mut state = self.action_grounding.lock().await;
1129 *state = ActionGroundingState::default();
1130 }
1131
1132 async fn record_read_observation(&self, path: &str) {
1133 let normalized = normalize_workspace_path(path);
1134 let mut state = self.action_grounding.lock().await;
1135 let turn = state.turn_index;
1136 state.observed_paths.insert(normalized.clone(), turn);
1140 state.inspected_paths.insert(normalized, turn);
1141 }
1142
1143 async fn record_line_inspection(&self, path: &str) {
1144 let normalized = normalize_workspace_path(path);
1145 let mut state = self.action_grounding.lock().await;
1146 let turn = state.turn_index;
1147 state.observed_paths.insert(normalized.clone(), turn);
1148 state.inspected_paths.insert(normalized, turn);
1149 }
1150
1151 async fn record_verify_build_result(&self, ok: bool, output: &str) {
1152 let mut state = self.action_grounding.lock().await;
1153 let turn = state.turn_index;
1154 state.last_verify_build_turn = Some(turn);
1155 state.last_verify_build_ok = ok;
1156 if ok {
1157 state.code_changed_since_verify = false;
1158 state.last_failed_build_paths.clear();
1159 } else {
1160 state.last_failed_build_paths = parse_failing_paths_from_build_output(output);
1161 }
1162 }
1163
1164 fn record_session_verification(&mut self, ok: bool, summary: impl Into<String>) {
1165 self.session_memory.record_verification(ok, summary);
1166 }
1167
1168 async fn record_successful_mutation(&self, path: Option<&str>) {
1169 let mut state = self.action_grounding.lock().await;
1170 state.code_changed_since_verify = match path {
1171 Some(p) => is_code_like_path(p),
1172 None => true,
1173 };
1174 }
1175
1176 async fn validate_action_preconditions(&self, name: &str, args: &Value) -> Result<(), String> {
1177 if self
1178 .plan_execution_active
1179 .load(std::sync::atomic::Ordering::SeqCst)
1180 {
1181 if is_current_plan_irrelevant_tool(name) {
1182 return Err(format!(
1183 "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.",
1184 name
1185 ));
1186 }
1187
1188 if is_plan_scoped_tool(name) {
1189 let allowed_paths = self.current_plan_allowed_paths();
1190 if !allowed_paths.is_empty() {
1191 let in_allowed = match name {
1192 "auto_pin_context" => args
1193 .get("paths")
1194 .and_then(|v| v.as_array())
1195 .map(|paths| {
1196 !paths.is_empty()
1197 && paths.iter().all(|v| {
1198 v.as_str()
1199 .map(normalize_workspace_path)
1200 .map(|p| allowed_paths.contains(&p))
1201 .unwrap_or(false)
1202 })
1203 })
1204 .unwrap_or(false),
1205 "grep_files" | "list_files" => args
1206 .get("path")
1207 .and_then(|v| v.as_str())
1208 .map(normalize_workspace_path)
1209 .map(|p| allowed_paths.contains(&p))
1210 .unwrap_or(false),
1211 _ => action_target_path(name, args)
1212 .map(|p| allowed_paths.contains(&p))
1213 .unwrap_or(false),
1214 };
1215
1216 if !in_allowed {
1217 let allowed = allowed_paths
1218 .iter()
1219 .map(|p| format!("`{}`", p))
1220 .collect::<Vec<_>>()
1221 .join(", ");
1222 return Err(format!(
1223 "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: {}.",
1224 allowed
1225 ));
1226 }
1227 }
1228 }
1229
1230 if matches!(name, "edit_file" | "multi_search_replace" | "patch_hunk") {
1231 if let Some(target) = action_target_path(name, args) {
1232 let state = self.action_grounding.lock().await;
1233 let recently_inspected = state
1234 .inspected_paths
1235 .get(&target)
1236 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1237 .unwrap_or(false);
1238 drop(state);
1239 if !recently_inspected {
1240 return Err(format!(
1241 "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.",
1242 name, target
1243 ));
1244 }
1245 }
1246 }
1247 }
1248
1249 if self.workflow_mode.is_read_only() && name == "auto_pin_context" {
1250 return Err(
1251 "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."
1252 .to_string(),
1253 );
1254 }
1255
1256 if self.workflow_mode.is_read_only() && is_destructive_tool(name) {
1257 if name == "shell" {
1258 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
1259 let risk = crate::tools::guard::classify_bash_risk(command);
1260 if !matches!(risk, crate::tools::RiskLevel::Safe) {
1261 return Err(format!(
1262 "Action blocked: workflow mode `{}` is read-only for risky or mutating operations. Switch to `/code` or `/auto` before making changes.",
1263 self.workflow_mode.label()
1264 ));
1265 }
1266 } else {
1267 return Err(format!(
1268 "Action blocked: workflow mode `{}` is read-only. Use `/code` to implement changes or `/auto` to leave mode selection to Hematite.",
1269 self.workflow_mode.label()
1270 ));
1271 }
1272 }
1273
1274 let normalized_target = action_target_path(name, args);
1275 if let Some(target) = normalized_target.as_deref() {
1276 if matches!(
1277 name,
1278 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
1279 ) {
1280 if let Some(prompt) = self.latest_user_prompt() {
1281 if docs_edit_without_explicit_request(prompt, target) {
1282 return Err(format!(
1283 "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.",
1284 target
1285 ));
1286 }
1287 }
1288 }
1289 let path_exists = std::path::Path::new(target).exists();
1290 if path_exists {
1291 let state = self.action_grounding.lock().await;
1292 let pinned = self.pinned_files.lock().await;
1293 let pinned_match = pinned.keys().any(|p| normalize_workspace_path(p) == target);
1294 drop(pinned);
1295
1296 let needs_exact_window = matches!(name, "edit_file" | "multi_search_replace");
1301 let recently_inspected = state
1302 .inspected_paths
1303 .get(target)
1304 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1305 .unwrap_or(false);
1306 let same_turn_read = state
1307 .observed_paths
1308 .get(target)
1309 .map(|turn| state.turn_index.saturating_sub(*turn) == 0)
1310 .unwrap_or(false);
1311 let recent_observed = state
1312 .observed_paths
1313 .get(target)
1314 .map(|turn| state.turn_index.saturating_sub(*turn) <= 3)
1315 .unwrap_or(false);
1316
1317 if needs_exact_window {
1318 if !recently_inspected && !same_turn_read && !pinned_match {
1319 return Err(format!(
1320 "Action blocked: `{}` on '{}' requires a line-level inspection first. \
1321 Use `inspect_lines` on the target region to get the exact current text \
1322 (whitespace and indentation included), then retry the edit.",
1323 name, target
1324 ));
1325 }
1326 } else if !recent_observed && !pinned_match {
1327 return Err(format!(
1328 "Action blocked: `{}` on '{}' requires recent file evidence. Use `read_file` or `inspect_lines` on that path first, or pin the file into active context.",
1329 name, target
1330 ));
1331 }
1332 }
1333 }
1334
1335 if is_mcp_mutating_tool(name) {
1336 return Err(format!(
1337 "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.",
1338 name
1339 ));
1340 }
1341
1342 if is_mcp_workspace_read_tool(name) {
1343 return Err(format!(
1344 "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.",
1345 name
1346 ));
1347 }
1348
1349 if matches!(
1352 name,
1353 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
1354 ) {
1355 if let Some(target) = normalized_target.as_deref() {
1356 let state = self.action_grounding.lock().await;
1357 if state.code_changed_since_verify
1358 && !state.last_verify_build_ok
1359 && !state.last_failed_build_paths.is_empty()
1360 && !state.last_failed_build_paths.iter().any(|p| p == target)
1361 {
1362 let files = state
1363 .last_failed_build_paths
1364 .iter()
1365 .map(|p| format!("`{}`", p))
1366 .collect::<Vec<_>>()
1367 .join(", ");
1368 return Err(format!(
1369 "Action blocked: the build is broken. Fix the errors in {} before editing other files. Run `verify_build` to confirm the fix, then continue.",
1370 files
1371 ));
1372 }
1373 }
1374 }
1375
1376 if name == "git_commit" || name == "git_push" {
1377 let state = self.action_grounding.lock().await;
1378 if state.code_changed_since_verify && !state.last_verify_build_ok {
1379 return Err(format!(
1380 "Action blocked: `{}` requires a successful `verify_build` after the latest code edits. Run verification first so Hematite has proof that the tree is build-clean.",
1381 name
1382 ));
1383 }
1384 }
1385
1386 if name == "shell" {
1387 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
1388 if shell_looks_like_structured_host_inspection(command) {
1389 let topic = preferred_host_inspection_topic(command)
1393 .or_else(|| {
1394 self.latest_user_prompt()
1395 .and_then(|p| preferred_host_inspection_topic(p))
1396 })
1397 .unwrap_or("summary")
1398 .to_string();
1399
1400 {
1401 let mut state = self.action_grounding.lock().await;
1402 let current_turn = state.turn_index;
1403 if let Some(turn) = state.redirected_host_inspection_topics.get(&topic) {
1404 if *turn == current_turn {
1405 return Err(format!(
1406 "Action already handled: The diagnostic data for topic `{topic}` was already provided in a previous redirected shell result this turn. Do not retry the same shell command."
1407 ));
1408 }
1409 }
1410 state
1411 .redirected_host_inspection_topics
1412 .insert(topic.clone(), current_turn);
1413 }
1414
1415 let path_val = self
1416 .latest_user_prompt()
1417 .and_then(|p| {
1418 p.split_whitespace()
1420 .find(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
1421 .map(|s| {
1422 s.trim_matches(|c: char| {
1423 !c.is_alphanumeric() && c != '.' && c != '/' && c != '\\'
1424 })
1425 })
1426 })
1427 .unwrap_or("");
1428
1429 let redirect_args = if !path_val.is_empty() {
1430 serde_json::json!({ "topic": topic, "path": path_val })
1431 } else {
1432 serde_json::json!({ "topic": topic })
1433 };
1434
1435 let result = crate::tools::host_inspect::inspect_host(&redirect_args).await;
1436 return match result {
1437 Ok(output) => Err(format!(
1438 "[successfully 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. Call inspect_host directly with the correct topic for any further diagnostics.]"
1439 )),
1440 Err(e) => Err(format!(
1441 "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, processes, network, services, ports, env_doctor, fix_plan, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, docker, wsl, ssh, env, hosts_file, installed_software, git_config, databases, disk_benchmark, directory.",
1442 )),
1443 };
1444 }
1445 let reason = args
1446 .get("reason")
1447 .and_then(|v| v.as_str())
1448 .unwrap_or("")
1449 .trim();
1450 let risk = crate::tools::guard::classify_bash_risk(command);
1451 if !matches!(risk, crate::tools::RiskLevel::Safe) && reason.is_empty() {
1452 return Err(
1453 "Action blocked: risky `shell` calls require a concrete `reason` argument that explains what is being verified or changed."
1454 .to_string(),
1455 );
1456 }
1457 }
1458
1459 Ok(())
1460 }
1461
1462 fn build_action_receipt(
1463 &self,
1464 name: &str,
1465 args: &Value,
1466 output: &str,
1467 is_error: bool,
1468 ) -> Option<ChatMessage> {
1469 if is_error || !is_destructive_tool(name) {
1470 return None;
1471 }
1472
1473 let mut receipt = String::from("[ACTION RECEIPT]\n");
1474 receipt.push_str(&format!("- tool: {}\n", name));
1475 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
1476 receipt.push_str(&format!("- target: {}\n", path));
1477 }
1478 if name == "shell" {
1479 if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
1480 receipt.push_str(&format!("- command: {}\n", command));
1481 }
1482 if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
1483 if !reason.trim().is_empty() {
1484 receipt.push_str(&format!("- reason: {}\n", reason.trim()));
1485 }
1486 }
1487 }
1488 let first_line = output.lines().next().unwrap_or(output).trim();
1489 receipt.push_str(&format!("- outcome: {}\n", first_line));
1490 Some(ChatMessage::system(&receipt))
1491 }
1492
1493 fn replace_mcp_tool_definitions(&mut self, mcp_tools: &[crate::agent::mcp::McpTool]) {
1494 self.tools
1495 .retain(|tool| !tool.function.name.starts_with("mcp__"));
1496 self.tools
1497 .extend(mcp_tools.iter().map(|tool| ToolDefinition {
1498 tool_type: "function".into(),
1499 function: ToolFunction {
1500 name: tool.name.clone(),
1501 description: tool.description.clone().unwrap_or_default(),
1502 parameters: tool.input_schema.clone(),
1503 },
1504 metadata: crate::agent::inference::tool_metadata_for_name(&tool.name),
1505 }));
1506 }
1507
1508 async fn emit_mcp_runtime_status(&self, tx: &mpsc::Sender<InferenceEvent>) {
1509 let summary = {
1510 let mcp = self.mcp_manager.lock().await;
1511 mcp.runtime_report()
1512 };
1513 let _ = tx
1514 .send(InferenceEvent::McpStatus {
1515 state: summary.state,
1516 summary: summary.summary,
1517 })
1518 .await;
1519 }
1520
1521 async fn refresh_mcp_tools(
1522 &mut self,
1523 tx: &mpsc::Sender<InferenceEvent>,
1524 ) -> Result<Vec<crate::agent::mcp::McpTool>, Box<dyn std::error::Error + Send + Sync>> {
1525 let mcp_tools = {
1526 let mut mcp = self.mcp_manager.lock().await;
1527 match mcp.initialize_all().await {
1528 Ok(()) => mcp.discover_tools().await,
1529 Err(e) => {
1530 drop(mcp);
1531 self.replace_mcp_tool_definitions(&[]);
1532 self.emit_mcp_runtime_status(tx).await;
1533 return Err(e.into());
1534 }
1535 }
1536 };
1537
1538 self.replace_mcp_tool_definitions(&mcp_tools);
1539 self.emit_mcp_runtime_status(tx).await;
1540 Ok(mcp_tools)
1541 }
1542
1543 pub async fn initialize_mcp(
1545 &mut self,
1546 tx: &mpsc::Sender<InferenceEvent>,
1547 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1548 let _ = self.refresh_mcp_tools(tx).await?;
1549 Ok(())
1550 }
1551
1552 pub async fn run_turn(
1558 &mut self,
1559 user_turn: &UserTurn,
1560 tx: mpsc::Sender<InferenceEvent>,
1561 yolo: bool,
1562 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1563 let user_input = user_turn.text.as_str();
1564 if user_input.trim() == "/new" {
1566 self.history.clear();
1567 self.reasoning_history = None;
1568 self.session_memory.clear();
1569 self.running_summary = None;
1570 self.correction_hints.clear();
1571 self.pinned_files.lock().await.clear();
1572 self.reset_action_grounding().await;
1573 reset_task_files();
1574 let _ = std::fs::remove_file(session_path());
1575 self.save_empty_session();
1576 self.emit_compaction_pressure(&tx).await;
1577 self.emit_prompt_pressure_idle(&tx).await;
1578 for chunk in chunk_text(
1579 "Fresh task context started. Chat history, pins, and task files cleared. Saved memory remains available.",
1580 8,
1581 ) {
1582 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1583 }
1584 let _ = tx.send(InferenceEvent::Done).await;
1585 return Ok(());
1586 }
1587
1588 if user_input.trim() == "/forget" {
1589 self.history.clear();
1590 self.reasoning_history = None;
1591 self.session_memory.clear();
1592 self.running_summary = None;
1593 self.correction_hints.clear();
1594 self.pinned_files.lock().await.clear();
1595 self.reset_action_grounding().await;
1596 reset_task_files();
1597 purge_persistent_memory();
1598 tokio::task::block_in_place(|| self.vein.reset());
1599 let _ = std::fs::remove_file(session_path());
1600 self.save_empty_session();
1601 self.emit_compaction_pressure(&tx).await;
1602 self.emit_prompt_pressure_idle(&tx).await;
1603 for chunk in chunk_text(
1604 "Hard forget complete. Chat history, saved memory, task files, and the Vein index were purged.",
1605 8,
1606 ) {
1607 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1608 }
1609 let _ = tx.send(InferenceEvent::Done).await;
1610 return Ok(());
1611 }
1612
1613 if user_input.trim() == "/vein-inspect" {
1614 let indexed = self.refresh_vein_index();
1615 let report = self.build_vein_inspection_report(indexed);
1616 let snapshot = tokio::task::block_in_place(|| self.vein.inspect_snapshot(1));
1617 let _ = tx
1618 .send(InferenceEvent::VeinStatus {
1619 file_count: snapshot.indexed_source_files + snapshot.indexed_docs,
1620 embedded_count: snapshot.embedded_source_doc_chunks,
1621 docs_only: self.vein_docs_only_mode(),
1622 })
1623 .await;
1624 for chunk in chunk_text(&report, 8) {
1625 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1626 }
1627 let _ = tx.send(InferenceEvent::Done).await;
1628 return Ok(());
1629 }
1630
1631 if user_input.trim() == "/workspace-profile" {
1632 let root = crate::tools::file_ops::workspace_root();
1633 let _ = crate::agent::workspace_profile::ensure_workspace_profile(&root);
1634 let report = crate::agent::workspace_profile::profile_report(&root);
1635 for chunk in chunk_text(&report, 8) {
1636 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1637 }
1638 let _ = tx.send(InferenceEvent::Done).await;
1639 return Ok(());
1640 }
1641
1642 if user_input.trim() == "/rules" {
1643 let root = crate::tools::file_ops::workspace_root();
1644 let rules_path = root.join(".hematite").join("rules.md");
1645 let report = if rules_path.exists() {
1646 match std::fs::read_to_string(&rules_path) {
1647 Ok(content) => format!(
1648 "## Behavioral Rules (.hematite/rules.md)\n\n{}\n\n---\nTo update: ask Hematite to edit your rules, or open `.hematite/rules.md` directly. Changes take effect on the next turn.",
1649 content.trim()
1650 ),
1651 Err(e) => format!("Error reading .hematite/rules.md: {e}"),
1652 }
1653 } else {
1654 format!(
1655 "No behavioral rules file found at `.hematite/rules.md`.\n\nCreate it to add custom behavioral guidelines — they are injected into the system prompt on every turn and apply to any model you load.\n\nExample: ask Hematite to \"create a rules.md with simplicity-first and surgical-edit guidelines\" and it will write the file for you.\n\nExpected path: {}",
1656 rules_path.display()
1657 )
1658 };
1659 for chunk in chunk_text(&report, 8) {
1660 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1661 }
1662 let _ = tx.send(InferenceEvent::Done).await;
1663 return Ok(());
1664 }
1665
1666 if user_input.trim() == "/vein-reset" {
1667 tokio::task::block_in_place(|| self.vein.reset());
1668 let _ = tx
1669 .send(InferenceEvent::VeinStatus {
1670 file_count: 0,
1671 embedded_count: 0,
1672 docs_only: self.vein_docs_only_mode(),
1673 })
1674 .await;
1675 for chunk in chunk_text("Vein index cleared. Will rebuild on the next turn.", 8) {
1676 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1677 }
1678 let _ = tx.send(InferenceEvent::Done).await;
1679 return Ok(());
1680 }
1681
1682 let config = crate::agent::config::load_config();
1684 self.recovery_context.clear();
1685 let manual_runtime_refresh = user_input.trim() == "/runtime-refresh";
1686 if !manual_runtime_refresh {
1687 if let Some((model_id, context_length, changed)) = self
1688 .refresh_runtime_profile_and_report(&tx, "turn_start")
1689 .await
1690 {
1691 if changed {
1692 let _ = tx
1693 .send(InferenceEvent::Thought(format!(
1694 "Runtime refresh: using model `{}` with CTX {} for this turn.",
1695 model_id, context_length
1696 )))
1697 .await;
1698 }
1699 }
1700 }
1701 self.emit_compaction_pressure(&tx).await;
1702 let current_model = self.engine.current_model();
1703 self.engine.set_gemma_native_formatting(
1704 crate::agent::config::effective_gemma_native_formatting(&config, ¤t_model),
1705 );
1706 let _turn_id = self.begin_grounded_turn().await;
1707 let _hook_runner = crate::agent::hooks::HookRunner::new(config.hooks.clone());
1708 let mcp_tools = match self.refresh_mcp_tools(&tx).await {
1709 Ok(tools) => tools,
1710 Err(e) => {
1711 let _ = tx
1712 .send(InferenceEvent::Error(format!("MCP refresh failed: {}", e)))
1713 .await;
1714 Vec::new()
1715 }
1716 };
1717
1718 let effective_fast = config
1720 .fast_model
1721 .clone()
1722 .or_else(|| self.fast_model.clone());
1723 let effective_think = config
1724 .think_model
1725 .clone()
1726 .or_else(|| self.think_model.clone());
1727
1728 if user_input.trim() == "/lsp" {
1730 let mut lsp = self.lsp_manager.lock().await;
1731 match lsp.start_servers().await {
1732 Ok(_) => {
1733 let _ = tx
1734 .send(InferenceEvent::MutedToken(
1735 "LSP: Servers Initialized OK.".to_string(),
1736 ))
1737 .await;
1738 }
1739 Err(e) => {
1740 let _ = tx
1741 .send(InferenceEvent::Error(format!(
1742 "LSP: Failed to start servers - {}",
1743 e
1744 )))
1745 .await;
1746 }
1747 }
1748 let _ = tx.send(InferenceEvent::Done).await;
1749 return Ok(());
1750 }
1751
1752 if user_input.trim() == "/runtime-refresh" {
1753 match self
1754 .refresh_runtime_profile_and_report(&tx, "manual_command")
1755 .await
1756 {
1757 Some((model_id, context_length, changed)) => {
1758 let msg = if changed {
1759 format!(
1760 "Runtime profile refreshed. Model: {} | CTX: {}",
1761 model_id, context_length
1762 )
1763 } else {
1764 format!(
1765 "Runtime profile unchanged. Model: {} | CTX: {}",
1766 model_id, context_length
1767 )
1768 };
1769 for chunk in chunk_text(&msg, 8) {
1770 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1771 }
1772 }
1773 None => {
1774 let _ = tx
1775 .send(InferenceEvent::Error(
1776 "Runtime refresh failed: LM Studio profile could not be read."
1777 .to_string(),
1778 ))
1779 .await;
1780 }
1781 }
1782 let _ = tx.send(InferenceEvent::Done).await;
1783 return Ok(());
1784 }
1785
1786 if user_input.trim() == "/ask" {
1787 self.set_workflow_mode(WorkflowMode::Ask);
1788 for chunk in chunk_text(
1789 "Workflow mode: ASK. Stay read-only, explain, inspect, and answer without making changes.",
1790 8,
1791 ) {
1792 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1793 }
1794 let _ = tx.send(InferenceEvent::Done).await;
1795 return Ok(());
1796 }
1797
1798 if user_input.trim() == "/code" {
1799 self.set_workflow_mode(WorkflowMode::Code);
1800 let mut message =
1801 "Workflow mode: CODE. Make changes when needed, but keep proof-before-action and verification discipline.".to_string();
1802 if let Some(plan) = self.current_plan_summary() {
1803 message.push_str(&format!(" Current plan: {plan}."));
1804 }
1805 for chunk in chunk_text(&message, 8) {
1806 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1807 }
1808 let _ = tx.send(InferenceEvent::Done).await;
1809 return Ok(());
1810 }
1811
1812 if user_input.trim() == "/architect" {
1813 self.set_workflow_mode(WorkflowMode::Architect);
1814 let mut message =
1815 "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();
1816 if let Some(plan) = self.current_plan_summary() {
1817 message.push_str(&format!(" Existing plan: {plan}."));
1818 }
1819 for chunk in chunk_text(&message, 8) {
1820 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1821 }
1822 let _ = tx.send(InferenceEvent::Done).await;
1823 return Ok(());
1824 }
1825
1826 if user_input.trim() == "/read-only" {
1827 self.set_workflow_mode(WorkflowMode::ReadOnly);
1828 for chunk in chunk_text(
1829 "Workflow mode: READ-ONLY. Analysis only. Do not modify files, run mutating shell commands, or commit changes.",
1830 8,
1831 ) {
1832 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1833 }
1834 let _ = tx.send(InferenceEvent::Done).await;
1835 return Ok(());
1836 }
1837
1838 if user_input.trim() == "/auto" {
1839 self.set_workflow_mode(WorkflowMode::Auto);
1840 for chunk in chunk_text(
1841 "Workflow mode: AUTO. Hematite will choose the narrowest effective path for the request.",
1842 8,
1843 ) {
1844 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1845 }
1846 let _ = tx.send(InferenceEvent::Done).await;
1847 return Ok(());
1848 }
1849
1850 if user_input.trim() == "/chat" {
1851 self.set_workflow_mode(WorkflowMode::Chat);
1852 let _ = tx.send(InferenceEvent::Done).await;
1853 return Ok(());
1854 }
1855
1856 if user_input.trim() == "/teach" {
1857 self.set_workflow_mode(WorkflowMode::Teach);
1858 for chunk in chunk_text(
1859 "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.",
1860 8,
1861 ) {
1862 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1863 }
1864 let _ = tx.send(InferenceEvent::Done).await;
1865 return Ok(());
1866 }
1867
1868 if user_input.trim() == "/reroll" {
1869 let soul = crate::ui::hatch::generate_soul_random();
1870 self.snark = soul.snark;
1871 self.chaos = soul.chaos;
1872 self.soul_personality = soul.personality.clone();
1873 let species = soul.species.clone();
1878 if let Some(eng) = Arc::get_mut(&mut self.engine) {
1879 eng.species = species.clone();
1880 }
1881 let shiny_tag = if soul.shiny { " 🌟 SHINY" } else { "" };
1882 let _ = tx
1883 .send(InferenceEvent::SoulReroll {
1884 species: soul.species.clone(),
1885 rarity: soul.rarity.label().to_string(),
1886 shiny: soul.shiny,
1887 personality: soul.personality.clone(),
1888 })
1889 .await;
1890 for chunk in chunk_text(
1891 &format!(
1892 "A new companion awakens!\n[{}{}] {} — \"{}\"",
1893 soul.rarity.label(),
1894 shiny_tag,
1895 soul.species,
1896 soul.personality
1897 ),
1898 8,
1899 ) {
1900 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1901 }
1902 let _ = tx.send(InferenceEvent::Done).await;
1903 return Ok(());
1904 }
1905
1906 if user_input.trim() == "/agent" {
1907 self.set_workflow_mode(WorkflowMode::Auto);
1908 let _ = tx.send(InferenceEvent::Done).await;
1909 return Ok(());
1910 }
1911
1912 let implement_plan_alias = user_input.trim() == "/implement-plan";
1913 if implement_plan_alias
1914 && !self
1915 .session_memory
1916 .current_plan
1917 .as_ref()
1918 .map(|plan| plan.has_signal())
1919 .unwrap_or(false)
1920 {
1921 for chunk in chunk_text(
1922 "No saved architect handoff is active. Run `/architect` first, or switch to `/code` with an explicit implementation request.",
1923 8,
1924 ) {
1925 let _ = tx.send(InferenceEvent::Token(chunk)).await;
1926 }
1927 let _ = tx.send(InferenceEvent::Done).await;
1928 return Ok(());
1929 }
1930
1931 let mut effective_user_input = if implement_plan_alias {
1932 self.set_workflow_mode(WorkflowMode::Code);
1933 implement_current_plan_prompt().to_string()
1934 } else {
1935 user_input.trim().to_string()
1936 };
1937 if let Some((mode, rest)) = parse_inline_workflow_prompt(user_input) {
1938 self.set_workflow_mode(mode);
1939 effective_user_input = rest.to_string();
1940 }
1941 let transcript_user_input = if implement_plan_alias {
1942 transcript_user_turn_text(user_turn, "/implement-plan")
1943 } else {
1944 transcript_user_turn_text(user_turn, &effective_user_input)
1945 };
1946 effective_user_input = apply_turn_attachments(user_turn, &effective_user_input);
1947 let implement_current_plan = self.workflow_mode == WorkflowMode::Code
1948 && is_current_plan_execution_request(&effective_user_input)
1949 && self
1950 .session_memory
1951 .current_plan
1952 .as_ref()
1953 .map(|plan| plan.has_signal())
1954 .unwrap_or(false);
1955 self.plan_execution_active
1956 .store(implement_current_plan, std::sync::atomic::Ordering::SeqCst);
1957 let _plan_execution_guard = PlanExecutionGuard {
1958 flag: self.plan_execution_active.clone(),
1959 };
1960 let intent = classify_query_intent(self.workflow_mode, &effective_user_input);
1961
1962 if let Some(answer_kind) = intent.direct_answer {
1964 match answer_kind {
1965 DirectAnswerKind::About => {
1966 let response = build_about_answer();
1967 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1968 .await;
1969 return Ok(());
1970 }
1971 DirectAnswerKind::LanguageCapability => {
1972 let response = build_language_capability_answer();
1973 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1974 .await;
1975 return Ok(());
1976 }
1977 DirectAnswerKind::UnsafeWorkflowPressure => {
1978 let response = build_unsafe_workflow_pressure_answer();
1979 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1980 .await;
1981 return Ok(());
1982 }
1983 DirectAnswerKind::SessionMemory => {
1984 let response = build_session_memory_answer();
1985 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1986 .await;
1987 return Ok(());
1988 }
1989 DirectAnswerKind::RecoveryRecipes => {
1990 let response = build_recovery_recipes_answer();
1991 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1992 .await;
1993 return Ok(());
1994 }
1995 DirectAnswerKind::McpLifecycle => {
1996 let response = build_mcp_lifecycle_answer();
1997 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
1998 .await;
1999 return Ok(());
2000 }
2001 DirectAnswerKind::AuthorizationPolicy => {
2002 let response = build_authorization_policy_answer();
2003 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2004 .await;
2005 return Ok(());
2006 }
2007 DirectAnswerKind::ToolClasses => {
2008 let response = build_tool_classes_answer();
2009 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2010 .await;
2011 return Ok(());
2012 }
2013 DirectAnswerKind::ToolRegistryOwnership => {
2014 let response = build_tool_registry_ownership_answer();
2015 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2016 .await;
2017 return Ok(());
2018 }
2019 DirectAnswerKind::SessionResetSemantics => {
2020 let response = build_session_reset_semantics_answer();
2021 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2022 .await;
2023 return Ok(());
2024 }
2025 DirectAnswerKind::ProductSurface => {
2026 let response = build_product_surface_answer();
2027 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2028 .await;
2029 return Ok(());
2030 }
2031 DirectAnswerKind::ReasoningSplit => {
2032 let response = build_reasoning_split_answer();
2033 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2034 .await;
2035 return Ok(());
2036 }
2037 DirectAnswerKind::Identity => {
2038 let response = build_identity_answer();
2039 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2040 .await;
2041 return Ok(());
2042 }
2043 DirectAnswerKind::WorkflowModes => {
2044 let response = build_workflow_modes_answer();
2045 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2046 .await;
2047 return Ok(());
2048 }
2049 DirectAnswerKind::GemmaNative => {
2050 let response = build_gemma_native_answer();
2051 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2052 .await;
2053 return Ok(());
2054 }
2055 DirectAnswerKind::GemmaNativeSettings => {
2056 let response = build_gemma_native_settings_answer();
2057 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2058 .await;
2059 return Ok(());
2060 }
2061 DirectAnswerKind::VerifyProfiles => {
2062 let response = build_verify_profiles_answer();
2063 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2064 .await;
2065 return Ok(());
2066 }
2067 DirectAnswerKind::Toolchain => {
2068 let lower = effective_user_input.to_lowercase();
2069 let topic = if (lower.contains("voice output") || lower.contains("voice"))
2070 && (lower.contains("lag")
2071 || lower.contains("behind visible text")
2072 || lower.contains("latency"))
2073 {
2074 "voice_latency_plan"
2075 } else {
2076 "all"
2077 };
2078 let response =
2079 crate::tools::toolchain::describe_toolchain(&serde_json::json!({
2080 "topic": topic,
2081 "question": effective_user_input,
2082 }))
2083 .await
2084 .unwrap_or_else(|e| format!("Error: {}", e));
2085 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2086 .await;
2087 return Ok(());
2088 }
2089 DirectAnswerKind::ArchitectSessionResetPlan => {
2090 let plan = build_architect_session_reset_plan();
2091 let response = plan.to_markdown();
2092 let _ = crate::tools::plan::save_plan_handoff(&plan);
2093 self.session_memory.current_plan = Some(plan);
2094 self.emit_direct_response(&tx, user_input, &effective_user_input, &response)
2095 .await;
2096 return Ok(());
2097 }
2098 }
2099 }
2100
2101 if matches!(
2102 self.workflow_mode,
2103 WorkflowMode::Ask | WorkflowMode::ReadOnly
2104 ) && looks_like_mutation_request(&effective_user_input)
2105 {
2106 let response = build_mode_redirect_answer(self.workflow_mode);
2107 self.history.push(ChatMessage::user(&effective_user_input));
2108 self.history.push(ChatMessage::assistant_text(&response));
2109 self.transcript.log_user(&transcript_user_input);
2110 self.transcript.log_agent(&response);
2111 for chunk in chunk_text(&response, 8) {
2112 if !chunk.is_empty() {
2113 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2114 }
2115 }
2116 let _ = tx.send(InferenceEvent::Done).await;
2117 self.trim_history(80);
2118 self.refresh_session_memory();
2119 self.save_session();
2120 return Ok(());
2121 }
2122
2123 if user_input.trim() == "/think" {
2124 self.think_mode = Some(true);
2125 for chunk in chunk_text("Think mode: ON — full chain-of-thought enabled.", 8) {
2126 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2127 }
2128 let _ = tx.send(InferenceEvent::Done).await;
2129 return Ok(());
2130 }
2131 if user_input.trim() == "/no_think" {
2132 self.think_mode = Some(false);
2133 for chunk in chunk_text(
2134 "Think mode: OFF — fast mode enabled (no chain-of-thought).",
2135 8,
2136 ) {
2137 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2138 }
2139 let _ = tx.send(InferenceEvent::Done).await;
2140 return Ok(());
2141 }
2142
2143 if user_input.trim_start().starts_with("/pin ") {
2145 let path = user_input.trim_start()[5..].trim();
2146 match std::fs::read_to_string(path) {
2147 Ok(content) => {
2148 self.pinned_files
2149 .lock()
2150 .await
2151 .insert(path.to_string(), content);
2152 let msg = format!(
2153 "Pinned: {} — this file is now locked in model context.",
2154 path
2155 );
2156 for chunk in chunk_text(&msg, 8) {
2157 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2158 }
2159 }
2160 Err(e) => {
2161 let _ = tx
2162 .send(InferenceEvent::Error(format!(
2163 "Failed to pin {}: {}",
2164 path, e
2165 )))
2166 .await;
2167 }
2168 }
2169 let _ = tx.send(InferenceEvent::Done).await;
2170 return Ok(());
2171 }
2172
2173 if user_input.trim_start().starts_with("/unpin ") {
2175 let path = user_input.trim_start()[7..].trim();
2176 if self.pinned_files.lock().await.remove(path).is_some() {
2177 let msg = format!("Unpinned: {} — file removed from active context.", path);
2178 for chunk in chunk_text(&msg, 8) {
2179 let _ = tx.send(InferenceEvent::Token(chunk)).await;
2180 }
2181 } else {
2182 let _ = tx
2183 .send(InferenceEvent::Error(format!(
2184 "File {} was not pinned.",
2185 path
2186 )))
2187 .await;
2188 }
2189 let _ = tx.send(InferenceEvent::Done).await;
2190 return Ok(());
2191 }
2192
2193 let tiny_context_mode = self.engine.current_context_length() <= 8_192;
2197 let mut base_prompt = self.engine.build_system_prompt(
2198 self.snark,
2199 self.chaos,
2200 self.brief,
2201 self.professional,
2202 &self.tools,
2203 self.reasoning_history.as_deref(),
2204 &mcp_tools,
2205 );
2206 if !tiny_context_mode {
2207 if let Some(hint) = &config.context_hint {
2208 if !hint.trim().is_empty() {
2209 base_prompt.push_str(&format!(
2210 "\n\n# Project Context (from .hematite/settings.json)\n{}",
2211 hint
2212 ));
2213 }
2214 }
2215 if let Some(profile_block) = crate::agent::workspace_profile::profile_prompt_block(
2216 &crate::tools::file_ops::workspace_root(),
2217 ) {
2218 base_prompt.push_str(&format!("\n\n{}", profile_block));
2219 }
2220 if let Some(ref l1) = self.l1_context {
2222 base_prompt.push_str(&format!("\n\n{}", l1));
2223 }
2224 if let Some(ref repo_map_block) = self.repo_map {
2225 base_prompt.push_str(&format!("\n\n{}", repo_map_block));
2226 }
2227 }
2228 let grounded_trace_mode = intent.grounded_trace_mode
2229 || intent.primary_class == QueryIntentClass::RuntimeDiagnosis;
2230 let capability_mode =
2231 intent.capability_mode || intent.primary_class == QueryIntentClass::Capability;
2232 let toolchain_mode =
2233 intent.toolchain_mode || intent.primary_class == QueryIntentClass::Toolchain;
2234 let host_inspection_mode = intent.host_inspection_mode;
2235 let maintainer_workflow_mode = intent.maintainer_workflow_mode
2236 || preferred_maintainer_workflow(&effective_user_input).is_some();
2237 let workspace_workflow_mode = intent.workspace_workflow_mode
2238 || preferred_workspace_workflow(&effective_user_input).is_some();
2239 let fix_plan_mode =
2240 preferred_host_inspection_topic(&effective_user_input) == Some("fix_plan");
2241 let architecture_overview_mode = intent.architecture_overview_mode;
2242 let capability_needs_repo = intent.capability_needs_repo;
2243 let mut system_msg = build_system_with_corrections(
2244 &base_prompt,
2245 &self.correction_hints,
2246 &self.gpu_state,
2247 &self.git_state,
2248 &config,
2249 );
2250 if tiny_context_mode {
2251 system_msg.push_str(
2252 "\n\n# TINY CONTEXT TURN MODE\n\
2253 Keep this turn compact. Prefer direct answers or one narrow tool step over broad exploration.\n",
2254 );
2255 }
2256 if !tiny_context_mode && grounded_trace_mode {
2257 system_msg.push_str(
2258 "\n\n# GROUNDED TRACE MODE\n\
2259 This turn is read-only architecture analysis unless the user explicitly asks otherwise.\n\
2260 Before answering trace, architecture, or control-flow questions, inspect the repo with real tools.\n\
2261 Use verified file paths, function names, structs, enums, channels, and event types only.\n\
2262 Prefer `trace_runtime_flow` for runtime wiring, session reset, startup, or reasoning/specular questions.\n\
2263 Treat `trace_runtime_flow` output as authoritative over your own memory.\n\
2264 If `trace_runtime_flow` fully answers the question, preserve its identifiers exactly and do not rename them in a styled rewrite.\n\
2265 Do not invent names such as synthetic channels or subsystems.\n\
2266 If a detail is not verified from the code or tool output, say `uncertain`.\n\
2267 For exact flow questions, answer in ordered steps and name the concrete functions and event types involved.\n"
2268 );
2269 }
2270 if !tiny_context_mode && capability_mode {
2271 system_msg.push_str(
2272 "\n\n# CAPABILITY QUESTION MODE\n\
2273 This is a product or capability question unless the user explicitly asks about repository implementation.\n\
2274 Answer from stable Hematite capabilities and current runtime state.\n\
2275 It is correct to mention that Hematite itself is built in Rust when relevant, but do not imply that its project support is limited to Rust.\n\
2276 Do NOT call repo-inspection tools like `read_file` or LSP lookup tools unless the user explicitly asks about implementation or file ownership.\n\
2277 Do NOT infer language or project support from unrelated dependencies, crates, or config files.\n\
2278 Describe language and project support in terms of real mechanisms: reading files, editing code, searching the workspace, running shell commands, build verification, language-aware tooling when available, web research, vision analysis, and optional MCP tools if configured.\n\
2279 If the user asks about languages, answer at the harness level: Hematite can help across many project languages even though Hematite itself is written in Rust.\n\
2280 Prefer real programming language examples like Python, JavaScript, TypeScript, Go, C#, or similar over file extensions like `.json` or `.md`.\n\
2281 For project-building questions, describe cross-project workflows like scaffolding files, shaping structure, implementing features, and running the appropriate local build or test commands for the target stack. Do not overclaim certainty.\n\
2282 Never mention raw `mcp__*` tool names unless those tools are active this turn and directly relevant.\n\
2283 Keep the answer short, plain, and ASCII-first.\n"
2284 );
2285 }
2286 if !tiny_context_mode && toolchain_mode {
2287 system_msg.push_str(
2288 "\n\n# TOOLCHAIN DISCIPLINE MODE\n\
2289 This turn is about Hematite's real built-in tools and how to choose them.\n\
2290 Prefer `describe_toolchain` before you try to summarize tool capabilities or propose a read-only investigation plan from memory.\n\
2291 Use only real built-in tool names.\n\
2292 Do not invent helper tools, MCP tool names, synthetic symbols, or example function names.\n\
2293 If `describe_toolchain` fully answers the question, preserve its output exactly instead of restyling it.\n\
2294 Be explicit about which tools are optional or conditional.\n"
2295 );
2296 }
2297 if !tiny_context_mode && host_inspection_mode {
2298 system_msg.push_str(
2299 "\n\n# HOST INSPECTION MODE\n\
2300 This turn is about the local machine. Make EXACTLY ONE `inspect_host` call using the best matching topic below, then answer. Do NOT call `summary` first. Do NOT make exploratory shell calls.\n\
2301 - Drive space / disk usage / free space / storage across drives → `storage`\n\
2302 - CPU model / RAM size / GPU name / hardware specs / BIOS / motherboard → `hardware`\n\
2303 - CPU % / RAM % / what is using resources / slow machine → `resource_load`\n\
2304 - Running processes / task manager / what is using RAM → `processes`\n\
2305 - Windows services / daemons / service state → `services`\n\
2306 - Listening ports / open ports / what process owns port N / which processes are listening / what is bound to a port → `ports` (waiting for inbound connections — includes PIDs and process names — do NOT also call `processes`)\n\
2307 - Active connections / established connections / what is connected right now / outbound sessions / show me connections / network connections → `connections` (live two-way sessions, NOT listening ports)\n\
2308 - Network adapters / IP / gateway / DNS overview → `network`\n\
2309 - Internet / online / can I reach the internet → `connectivity`\n\
2310 - Wi-Fi / wireless / signal strength / SSID → `wifi`\n\
2311 - VPN tunnel / VPN adapter → `vpn`\n\
2312 - Security / Defender / antivirus / firewall / UAC → `security`\n\
2313 - Windows Update / pending updates → `updates`\n\
2314 - Health report / system status overall → `health_report`\n\
2315 - PATH entries / raw PATH → `path`\n\
2316 - Installed developer tools / versions / toolchain → `toolchains`\n\
2317 - Environment/package-manager conflicts → `env_doctor`\n\
2318 - Fix a workstation problem (cargo not found, port in use, LM Studio) → `fix_plan`\n\
2319 - Recent Windows errors / warnings / event log / event viewer / show me errors / what failed recently → `log_check` (do NOT call health_report first)\n\
2320 - Repo / git / workspace health → `repo_doctor`\n\
2321 - List a specific directory → `directory` (pass `path` arg)\n\
2322 - Desktop or Downloads folder → `desktop` or `downloads`\n\
2323 NEVER use `disk` or `directory` for storage/space questions — use `storage`.\n\
2324 Only use `shell` if the question truly cannot be answered by any topic above.\n\
2325 NEVER tell the user to run PowerShell, cmd, or shell commands themselves. If the data is incomplete, say so and tell them to ask a more specific question instead.\n\
2326 NEVER expose internal tool names or API syntax (like `inspect_host(topic=...)`) in your response. Refer to capabilities in plain English: say 'ask me for a fix plan' not 'run inspect_host(topic=fix_plan)'.\n"
2327 );
2328 }
2329 if !tiny_context_mode && fix_plan_mode {
2330 system_msg.push_str(
2331 "\n\n# FIX PLAN MODE\n\
2332 This turn is a workstation remediation question, not just a diagnosis question.\n\
2333 Call `inspect_host` with `topic=fix_plan` first.\n\
2334 Do not start with `path`, `toolchains`, `env_doctor`, or `ports` unless the user explicitly asks for diagnosis details instead of a fix plan.\n\
2335 Keep the answer grounded, stepwise, and approval-aware.\n"
2336 );
2337 }
2338 if !tiny_context_mode && maintainer_workflow_mode {
2339 system_msg.push_str(
2340 "\n\n# HEMATITE MAINTAINER WORKFLOW MODE\n\
2341 This turn asks Hematite to run one of Hematite's own maintainer workflows, not invent an ad hoc shell command.\n\
2342 Prefer `run_hematite_maintainer_workflow` for existing Hematite workflows such as `clean.ps1`, `scripts/package-windows.ps1`, or `release.ps1`.\n\
2343 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\
2344 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"
2345 );
2346 }
2347 if !tiny_context_mode && workspace_workflow_mode {
2348 system_msg.push_str(
2349 "\n\n# WORKSPACE WORKFLOW MODE\n\
2350 This turn asks Hematite to run something in the active project workspace, not in Hematite's own source tree.\n\
2351 Prefer `run_workspace_workflow` for the current project's build, test, lint, fix, package scripts, just/task/make targets, local repo scripts, or an exact workspace command.\n\
2352 This tool always runs from the locked workspace root.\n\
2353 If no real project workspace is locked, say so and tell the user to relaunch Hematite in the target project directory.\n\
2354 Do not use `run_hematite_maintainer_workflow` unless the request is specifically about Hematite's own cleanup, packaging, or release scripts.\n"
2355 );
2356 }
2357
2358 if !tiny_context_mode && architecture_overview_mode {
2359 system_msg.push_str(
2360 "\n\n# ARCHITECTURE OVERVIEW DISCIPLINE MODE\n\
2361 For broad runtime or architecture walkthroughs, prefer authoritative tools first: `trace_runtime_flow` for control flow.\n\
2362 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\
2363 Preserve grounded tool output rather than restyling it into a larger answer.\n"
2364 );
2365 }
2366
2367 system_msg.push_str(&format!(
2369 "\n\n# WORKFLOW MODE\nCURRENT WORKFLOW: {}\n",
2370 self.workflow_mode.label()
2371 ));
2372 if tiny_context_mode {
2373 system_msg
2374 .push_str("Use the narrowest safe behavior for this mode. Keep the turn short.\n");
2375 } else {
2376 match self.workflow_mode {
2377 WorkflowMode::Auto => system_msg.push_str(
2378 "AUTO means choose the narrowest effective path for the request. Answer directly when stable product logic exists. Inspect before editing. Mutate only when the user is clearly asking for implementation.\n",
2379 ),
2380 WorkflowMode::Ask => system_msg.push_str(
2381 "ASK means analysis only. Stay read-only, inspect the repo, explain findings, and do not make changes unless the user explicitly switches modes.\n",
2382 ),
2383 WorkflowMode::Code => system_msg.push_str(
2384 "CODE means implementation is allowed when needed. Keep proof-before-action, verification, and edit precision discipline. If an active plan handoff exists in session memory or `.hematite/PLAN.md`, treat it as the implementation brief unless the user explicitly overrides it. For ordinary workspace inspection during implementation, use built-in read/edit tools first and do not reach for `mcp__filesystem__*` unless the user explicitly requires MCP.\n",
2385 ),
2386 WorkflowMode::Architect => system_msg.push_str(
2387 "ARCHITECT means plan first. Inspect, reason, and produce a concrete implementation approach before editing. Do not mutate code unless the user explicitly asks to implement. When you produce an implementation handoff, use these exact ASCII headings so Hematite can persist the plan: `# Goal`, `# Target Files`, `# Ordered Steps`, `# Verification`, `# Risks`, `# Open Questions`.\n",
2388 ),
2389 WorkflowMode::ReadOnly => system_msg.push_str(
2390 "READ-ONLY means analysis only. Do not modify files, run mutating shell commands, or commit changes.\n",
2391 ),
2392 WorkflowMode::Teach => system_msg.push_str(
2393 "TEACH means you are a senior technician giving the user a grounded, numbered walkthrough. \
2394 MANDATORY PROTOCOL for every admin/config/write task:\n\
2395 1. Call inspect_host with the most relevant topic(s) FIRST to observe the actual machine state.\n\
2396 2. Then deliver a numbered step-by-step tutorial that references what you actually observed — exact commands, exact paths, exact values.\n\
2397 3. End with a verification step the user can run to confirm success.\n\
2398 4. Do NOT execute write operations yourself. You are the teacher; the user performs the steps.\n\
2399 5. Treat the user as capable — give precise instructions, not hedged warnings.\n\
2400 Relevant inspect_host topics for common tasks: hardware (driver installs), security (firewall), ssh (SSH keys), wsl (WSL setup), env (PATH/env vars), services (service config), recent_crashes (troubleshooting), disk_health (storage issues).\n",
2401 ),
2402 WorkflowMode::Chat => {} }
2404 }
2405 if !tiny_context_mode && self.workflow_mode == WorkflowMode::Architect {
2406 system_msg.push_str("\n\n# ARCHITECT HANDOFF CONTRACT\n");
2407 system_msg.push_str(architect_handoff_contract());
2408 system_msg.push('\n');
2409 }
2410 if !tiny_context_mode && implement_current_plan {
2411 system_msg.push_str(
2412 "\n\n# CURRENT PLAN EXECUTION CONTRACT\n\
2413 The user explicitly asked you to implement the current saved plan.\n\
2414 Do not restate the plan, do not provide preliminary contracts, and do not stop at analysis.\n\
2415 Use the saved plan as the brief, gather only the minimum built-in file evidence you need, then start editing the target files.\n\
2416 Every file inspection or edit call must be path-scoped to one of the saved target files.\n\
2417 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",
2418 );
2419 if let Some(plan) = self.session_memory.current_plan.as_ref() {
2420 if !plan.target_files.is_empty() {
2421 system_msg.push_str("\n# CURRENT PLAN TARGET FILES\n");
2422 for path in &plan.target_files {
2423 system_msg.push_str(&format!("- {}\n", path));
2424 }
2425 }
2426 }
2427 }
2428 if !tiny_context_mode {
2429 let pinned = self.pinned_files.lock().await;
2430 if !pinned.is_empty() {
2431 system_msg.push_str("\n\n# ACTIVE CONTEXT (PINNED FILES)\n");
2432 system_msg.push_str("The following files are locked in your active memory for high-fidelity reference.\n\n");
2433 for (path, content) in pinned.iter() {
2434 system_msg.push_str(&format!("## FILE: {}\n```\n{}\n```\n\n", path, content));
2435 }
2436 }
2437 }
2438 if !tiny_context_mode {
2439 self.append_session_handoff(&mut system_msg);
2440 }
2441 let system_msg = if self.workflow_mode.is_chat() {
2444 self.build_chat_system_prompt()
2445 } else {
2446 system_msg
2447 };
2448 if self.history.is_empty() || self.history[0].role != "system" {
2449 self.history.insert(0, ChatMessage::system(&system_msg));
2450 } else {
2451 self.history[0] = ChatMessage::system(&system_msg);
2452 }
2453
2454 self.cancel_token
2456 .store(false, std::sync::atomic::Ordering::SeqCst);
2457
2458 self.reasoning_history = None;
2461
2462 let is_gemma = crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
2463 let user_content = match self.think_mode {
2464 Some(true) => format!("/think\n{}", effective_user_input),
2465 Some(false) => format!("/no_think\n{}", effective_user_input),
2466 None if !is_gemma
2471 && !self.workflow_mode.is_chat()
2472 && !is_quick_tool_request(&effective_user_input) =>
2473 {
2474 format!("/think\n{}", effective_user_input)
2475 }
2476 None => effective_user_input.clone(),
2477 };
2478 if let Some(image) = user_turn.attached_image.as_ref() {
2479 let image_url =
2480 crate::tools::vision::encode_image_as_data_url(std::path::Path::new(&image.path))
2481 .map_err(|e| format!("Image attachment failed for {}: {}", image.name, e))?;
2482 self.history
2483 .push(ChatMessage::user_with_image(&user_content, &image_url));
2484 } else {
2485 self.history.push(ChatMessage::user(&user_content));
2486 }
2487 self.transcript.log_user(&transcript_user_input);
2488
2489 let vein_docs_only = self.vein_docs_only_mode();
2493 let allow_vein_context = !self.workflow_mode.is_chat()
2494 || should_use_vein_in_chat(&effective_user_input, vein_docs_only);
2495 let (vein_context, vein_paths) = if allow_vein_context {
2496 self.refresh_vein_index();
2497 let _ = tx
2498 .send(InferenceEvent::VeinStatus {
2499 file_count: self.vein.file_count(),
2500 embedded_count: self.vein.embedded_chunk_count(),
2501 docs_only: vein_docs_only,
2502 })
2503 .await;
2504 match self.build_vein_context(&effective_user_input) {
2505 Some((ctx, paths)) => (Some(ctx), paths),
2506 None => (None, Vec::new()),
2507 }
2508 } else {
2509 (None, Vec::new())
2510 };
2511 if !vein_paths.is_empty() {
2512 let _ = tx
2513 .send(InferenceEvent::VeinContext { paths: vein_paths })
2514 .await;
2515 }
2516
2517 let routed_model = route_model(
2519 &effective_user_input,
2520 effective_fast.as_deref(),
2521 effective_think.as_deref(),
2522 )
2523 .map(|s| s.to_string());
2524
2525 let mut loop_intervention: Option<String> = None;
2526
2527 {
2534 let topics = crate::agent::routing::all_host_inspection_topics(&effective_user_input);
2535 if topics.len() >= 2 {
2536 let _ = tx
2537 .send(InferenceEvent::Thought(format!(
2538 "Harness pre-run: {} host inspection topics detected — running all before model turn.",
2539 topics.len()
2540 )))
2541 .await;
2542
2543 let topic_list = topics.join(", ");
2544 let mut combined = format!(
2545 "## HARNESS PRE-RUN RESULTS\n\
2546 The harness already ran inspect_host for the following topics: {topic_list}.\n\
2547 Use the tool results in context to answer. Do NOT repeat these tool calls.\n\n"
2548 );
2549
2550 let mut tool_calls = Vec::new();
2551 let mut tool_msgs = Vec::new();
2552
2553 for topic in &topics {
2554 let call_id = format!("prerun_{topic}");
2555 let args_val = serde_json::json!({ "topic": *topic, "max_entries": 20 });
2556 let args_str = serde_json::to_string(&args_val).unwrap_or_default();
2557
2558 tool_calls.push(crate::agent::inference::ToolCallResponse {
2559 id: call_id.clone(),
2560 call_type: "function".to_string(),
2561 function: crate::agent::inference::ToolCallFn {
2562 name: "inspect_host".to_string(),
2563 arguments: args_str,
2564 },
2565 });
2566
2567 let label = format!("### inspect_host(topic=\"{topic}\")\n");
2568 let _ = tx
2569 .send(InferenceEvent::ToolCallStart {
2570 id: call_id.clone(),
2571 name: "inspect_host".to_string(),
2572 args: format!("inspect host {topic}"),
2573 })
2574 .await;
2575
2576 match crate::tools::host_inspect::inspect_host(&args_val).await {
2577 Ok(out) => {
2578 let _ = tx
2579 .send(InferenceEvent::ToolCallResult {
2580 id: call_id.clone(),
2581 name: "inspect_host".to_string(),
2582 output: out.chars().take(300).collect::<String>() + "...",
2583 is_error: false,
2584 })
2585 .await;
2586 combined.push_str(&label);
2587 combined.push_str(&out);
2588 combined.push_str("\n\n");
2589 tool_msgs.push(ChatMessage::tool_result_for_model(
2590 &call_id,
2591 "inspect_host",
2592 &out,
2593 &self.engine.current_model(),
2594 ));
2595 }
2596 Err(e) => {
2597 let err_msg = format!("Error: {e}");
2598 combined.push_str(&label);
2599 combined.push_str(&err_msg);
2600 combined.push_str("\n\n");
2601 tool_msgs.push(ChatMessage::tool_result_for_model(
2602 &call_id,
2603 "inspect_host",
2604 &err_msg,
2605 &self.engine.current_model(),
2606 ));
2607 }
2608 }
2609 }
2610
2611 self.history
2613 .push(ChatMessage::assistant_tool_calls("", tool_calls));
2614 for msg in tool_msgs {
2615 self.history.push(msg);
2616 }
2617
2618 loop_intervention = Some(combined);
2619 }
2620 }
2621
2622 if loop_intervention.is_none() && needs_computation_sandbox(&effective_user_input) {
2629 loop_intervention = Some(
2630 "COMPUTATION INTEGRITY NOTICE: This query involves precise numeric computation. \
2631 Do NOT answer from training-data memory — memory answers for math are guesses. \
2632 Use `run_code` to compute the real result and return the actual output. \
2633 IMPORTANT: the `run_code` tool defaults to JavaScript (Deno). \
2634 If you write Python code, you MUST pass `language: \"python\"` explicitly. \
2635 If you write JavaScript/TypeScript, omit the language field or pass `language: \"javascript\"`. \
2636 Write the code, run it, return the result."
2637 .to_string(),
2638 );
2639 }
2640
2641 let mut implementation_started = false;
2642 let mut non_mutating_plan_steps = 0usize;
2643 let non_mutating_plan_soft_cap = 5usize;
2644 let non_mutating_plan_hard_cap = 8usize;
2645 let mut overview_runtime_trace: Option<String> = None;
2646
2647 let max_iters = 25;
2649 let mut consecutive_errors = 0;
2650 let mut empty_cleaned_nudges = 0u8;
2651 let mut first_iter = true;
2652 let _called_this_turn: std::collections::HashSet<String> = std::collections::HashSet::new();
2653 let _result_counts: std::collections::HashMap<String, usize> =
2655 std::collections::HashMap::new();
2656 let mut repeat_counts: std::collections::HashMap<String, usize> =
2658 std::collections::HashMap::new();
2659 let mut completed_tool_cache: std::collections::HashMap<String, CachedToolResult> =
2660 std::collections::HashMap::new();
2661 let mut successful_read_targets: std::collections::HashSet<String> =
2662 std::collections::HashSet::new();
2663 let mut successful_read_regions: std::collections::HashSet<(String, u64)> =
2665 std::collections::HashSet::new();
2666 let mut successful_grep_targets: std::collections::HashSet<String> =
2667 std::collections::HashSet::new();
2668 let mut no_match_grep_targets: std::collections::HashSet<String> =
2669 std::collections::HashSet::new();
2670 let mut broad_grep_targets: std::collections::HashSet<String> =
2671 std::collections::HashSet::new();
2672
2673 let mut turn_anchor = self.history.len().saturating_sub(1);
2675
2676 for _iter in 0..max_iters {
2677 let mut mutation_occurred = false;
2678 if self.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
2680 self.cancel_token
2681 .store(false, std::sync::atomic::Ordering::SeqCst);
2682 let _ = tx
2683 .send(InferenceEvent::Thought("Turn cancelled by user.".into()))
2684 .await;
2685 let _ = tx.send(InferenceEvent::Done).await;
2686 return Ok(());
2687 }
2688
2689 if self
2691 .compact_history_if_needed(&tx, Some(turn_anchor))
2692 .await?
2693 {
2694 turn_anchor = 2;
2697 }
2698
2699 let inject_vein = first_iter && !implement_current_plan;
2703 let messages = if implement_current_plan {
2704 first_iter = false;
2705 self.context_window_slice_from(turn_anchor)
2706 } else {
2707 first_iter = false;
2708 self.context_window_slice()
2709 };
2710
2711 let mut prompt_msgs = if let Some(intervention) = loop_intervention.take() {
2715 if crate::agent::inference::is_gemma4_model_name(&self.engine.current_model()) {
2718 let mut msgs = vec![self.history[0].clone()];
2719 msgs.push(ChatMessage::system(&intervention));
2720 msgs
2721 } else {
2722 let merged =
2723 format!("{}\n\n{}", self.history[0].content.as_str(), intervention);
2724 vec![ChatMessage::system(&merged)]
2725 }
2726 } else {
2727 vec![self.history[0].clone()]
2728 };
2729
2730 if inject_vein {
2734 if let Some(ref ctx) = vein_context.as_ref() {
2735 if crate::agent::inference::is_gemma4_model_name(&self.engine.current_model()) {
2736 prompt_msgs.push(ChatMessage::system(ctx));
2737 } else {
2738 let merged = format!("{}\n\n{}", prompt_msgs[0].content.as_str(), ctx);
2739 prompt_msgs[0] = ChatMessage::system(&merged);
2740 }
2741 }
2742 }
2743 prompt_msgs.extend(messages);
2744 if let Some(budget_note) =
2745 enforce_prompt_budget(&mut prompt_msgs, self.engine.current_context_length())
2746 {
2747 self.emit_operator_checkpoint(
2748 &tx,
2749 OperatorCheckpointState::BudgetReduced,
2750 budget_note,
2751 )
2752 .await;
2753 let recipe = plan_recovery(
2754 RecoveryScenario::PromptBudgetPressure,
2755 &self.recovery_context,
2756 );
2757 self.emit_recovery_recipe_summary(
2758 &tx,
2759 recipe.recipe.scenario.label(),
2760 compact_recovery_plan_summary(&recipe),
2761 )
2762 .await;
2763 }
2764 self.emit_prompt_pressure_for_messages(&tx, &prompt_msgs)
2765 .await;
2766
2767 let (mut text, mut tool_calls, usage, finish_reason) = match self
2768 .engine
2769 .call_with_tools(&prompt_msgs, &self.tools, routed_model.as_deref())
2770 .await
2771 {
2772 Ok(result) => result,
2773 Err(e) => {
2774 let class = classify_runtime_failure(&e);
2775 if should_retry_runtime_failure(class) {
2776 if self.recovery_context.consume_transient_retry() {
2777 let label = match class {
2778 RuntimeFailureClass::ProviderDegraded => "provider_degraded",
2779 _ => "empty_model_response",
2780 };
2781 self.transcript.log_system(&format!(
2782 "Automatic provider recovery triggered: {}",
2783 e.trim()
2784 ));
2785 self.emit_recovery_recipe_summary(
2786 &tx,
2787 label,
2788 compact_runtime_recovery_summary(class),
2789 )
2790 .await;
2791 let _ = tx
2792 .send(InferenceEvent::ProviderStatus {
2793 state: ProviderRuntimeState::Recovering,
2794 summary: compact_runtime_recovery_summary(class).into(),
2795 })
2796 .await;
2797 self.emit_operator_checkpoint(
2798 &tx,
2799 OperatorCheckpointState::RecoveringProvider,
2800 compact_runtime_recovery_summary(class),
2801 )
2802 .await;
2803 continue;
2804 }
2805 }
2806
2807 self.emit_runtime_failure(&tx, class, &e).await;
2808 break;
2809 }
2810 };
2811 self.emit_provider_live(&tx).await;
2812
2813 if let Some(ref u) = usage {
2815 let _ = tx.send(InferenceEvent::UsageUpdate(u.clone())).await;
2816 }
2817
2818 if tool_calls
2821 .as_ref()
2822 .map(|calls| calls.is_empty())
2823 .unwrap_or(true)
2824 {
2825 if let Some(raw_text) = text.as_deref() {
2826 let native_calls = crate::agent::inference::extract_native_tool_calls(raw_text);
2827 if !native_calls.is_empty() {
2828 tool_calls = Some(native_calls);
2829 let stripped =
2830 crate::agent::inference::strip_native_tool_call_text(raw_text);
2831 text = if stripped.trim().is_empty() {
2832 None
2833 } else {
2834 Some(stripped)
2835 };
2836 }
2837 }
2838 }
2839
2840 let tool_calls = tool_calls.filter(|c| !c.is_empty());
2843 let near_context_ceiling = usage
2844 .as_ref()
2845 .map(|u| u.prompt_tokens >= (self.engine.current_context_length() * 82 / 100))
2846 .unwrap_or(false);
2847
2848 if let Some(calls) = tool_calls {
2849 let (calls, prune_trace_note) =
2850 prune_architecture_trace_batch(calls, architecture_overview_mode);
2851 if let Some(note) = prune_trace_note {
2852 let _ = tx.send(InferenceEvent::Thought(note)).await;
2853 }
2854
2855 let (calls, prune_bloat_note) = prune_read_only_context_bloat_batch(
2856 calls,
2857 self.workflow_mode.is_read_only(),
2858 architecture_overview_mode,
2859 );
2860 if let Some(note) = prune_bloat_note {
2861 let _ = tx.send(InferenceEvent::Thought(note)).await;
2862 }
2863
2864 let (calls, prune_note) = prune_authoritative_tool_batch(
2865 calls,
2866 grounded_trace_mode,
2867 &effective_user_input,
2868 );
2869 if let Some(note) = prune_note {
2870 let _ = tx.send(InferenceEvent::Thought(note)).await;
2871 }
2872
2873 let (calls, prune_redir_note) = prune_redirected_shell_batch(calls);
2874 if let Some(note) = prune_redir_note {
2875 let _ = tx.send(InferenceEvent::Thought(note)).await;
2876 }
2877
2878 let (calls, batch_note) = order_batch_reads_first(calls);
2879 if let Some(note) = batch_note {
2880 let _ = tx.send(InferenceEvent::Thought(note)).await;
2881 }
2882
2883 if let Some(repeated_path) = calls
2884 .iter()
2885 .filter(|c| {
2886 let parsed = serde_json::from_str::<Value>(
2887 &crate::agent::inference::normalize_tool_argument_string(
2888 &c.function.name,
2889 &c.function.arguments,
2890 ),
2891 )
2892 .ok();
2893 let offset = parsed
2894 .as_ref()
2895 .and_then(|args| args.get("offset").and_then(|v| v.as_u64()))
2896 .unwrap_or(0);
2897 if offset < 200 {
2900 return true;
2901 }
2902 if let Some(path) = parsed
2903 .as_ref()
2904 .and_then(|args| args.get("path").and_then(|v| v.as_str()))
2905 {
2906 let normalized = normalize_workspace_path(path);
2907 return successful_read_regions.contains(&(normalized, offset));
2908 }
2909 false
2910 })
2911 .filter_map(|c| repeated_read_target(&c.function))
2912 .find(|path| successful_read_targets.contains(path))
2913 {
2914 loop_intervention = Some(format!(
2915 "STOP. Already read `{}` this turn. Use `inspect_lines` on the relevant window or a specific `grep_files`, then continue.",
2916 repeated_path
2917 ));
2918 let _ = tx
2919 .send(InferenceEvent::Thought(
2920 "Read discipline: preventing repeated full-file reads on the same path."
2921 .into(),
2922 ))
2923 .await;
2924 continue;
2925 }
2926
2927 if capability_mode
2928 && !capability_needs_repo
2929 && calls
2930 .iter()
2931 .all(|c| is_capability_probe_tool(&c.function.name))
2932 {
2933 loop_intervention = Some(
2934 "STOP. This is a stable capability question. Do not inspect the repository or call tools. \
2935 Answer directly from verified Hematite capabilities, current runtime state, and the documented product boundary. \
2936 Do not mention raw `mcp__*` names unless they are active and directly relevant."
2937 .to_string(),
2938 );
2939 let _ = tx
2940 .send(InferenceEvent::Thought(
2941 "Capability mode: skipping unnecessary repo-inspection tools and answering directly."
2942 .into(),
2943 ))
2944 .await;
2945 continue;
2946 }
2947
2948 let raw_content = text.as_deref().unwrap_or(" ");
2951
2952 if let Some(thought) = crate::agent::inference::extract_think_block(raw_content) {
2953 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
2954 self.reasoning_history = Some(thought);
2956 }
2957
2958 let stored_tool_call_content = if implement_current_plan {
2961 cap_output(raw_content, 1200)
2962 } else {
2963 raw_content.to_string()
2964 };
2965 self.history.push(ChatMessage::assistant_tool_calls(
2966 &stored_tool_call_content,
2967 calls.clone(),
2968 ));
2969
2970 let mut results = Vec::new();
2972 let gemma4_model =
2973 crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
2974 let latest_user_prompt = self.latest_user_prompt();
2975 let mut seen_call_keys = std::collections::HashSet::new();
2976 let mut deduped_calls = Vec::new();
2977 for call in calls.clone() {
2978 let (normalized_name, normalized_args) = normalized_tool_call_for_execution(
2979 &call.function.name,
2980 &call.function.arguments,
2981 gemma4_model,
2982 latest_user_prompt,
2983 );
2984 let key = canonical_tool_call_key(&normalized_name, &normalized_args);
2985 if seen_call_keys.insert(key) {
2986 let repeat_guard_exempt = matches!(
2987 normalized_name.as_str(),
2988 "verify_build" | "git_commit" | "git_push"
2989 );
2990 if !repeat_guard_exempt {
2991 if let Some(cached) = completed_tool_cache
2992 .get(&canonical_tool_call_key(&normalized_name, &normalized_args))
2993 {
2994 let _ = tx
2995 .send(InferenceEvent::Thought(
2996 "Cached tool result reused: identical built-in invocation already completed earlier in this turn."
2997 .to_string(),
2998 ))
2999 .await;
3000 loop_intervention = Some(format!(
3001 "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.",
3002 cached.tool_name
3003 ));
3004 continue;
3005 }
3006 }
3007 deduped_calls.push(call);
3008 } else {
3009 let _ = tx
3010 .send(InferenceEvent::Thought(
3011 "Duplicate tool call skipped: identical built-in invocation already ran this turn."
3012 .to_string(),
3013 ))
3014 .await;
3015 }
3016 }
3017
3018 let (parallel_calls, serial_calls): (Vec<_>, Vec<_>) = deduped_calls
3020 .into_iter()
3021 .partition(|c| is_parallel_safe(&c.function.name));
3022
3023 if !parallel_calls.is_empty() {
3025 let mut tasks = Vec::new();
3026 for call in parallel_calls {
3027 let tx_clone = tx.clone();
3028 let config_clone = config.clone();
3029 let call_with_id = call.clone();
3031 tasks.push(self.process_tool_call(
3032 call_with_id.function,
3033 config_clone,
3034 yolo,
3035 tx_clone,
3036 call_with_id.id,
3037 ));
3038 }
3039 results.extend(futures::future::join_all(tasks).await);
3041 }
3042
3043 for call in serial_calls {
3045 results.push(
3046 self.process_tool_call(
3047 call.function,
3048 config.clone(),
3049 yolo,
3050 tx.clone(),
3051 call.id,
3052 )
3053 .await,
3054 );
3055 }
3056
3057 let mut authoritative_tool_output: Option<String> = None;
3059 let mut blocked_policy_output: Option<String> = None;
3060 let mut recoverable_policy_intervention: Option<String> = None;
3061 let mut recoverable_policy_recipe: Option<RecoveryScenario> = None;
3062 let mut recoverable_policy_checkpoint: Option<(OperatorCheckpointState, String)> =
3063 None;
3064 for res in results {
3065 let call_id = res.call_id.clone();
3066 let tool_name = res.tool_name.clone();
3067 let final_output = res.output.clone();
3068 let is_error = res.is_error;
3069 for msg in res.msg_results {
3070 self.history.push(msg);
3071 }
3072
3073 if matches!(
3075 tool_name.as_str(),
3076 "patch_hunk" | "write_file" | "edit_file" | "multi_search_replace"
3077 ) {
3078 mutation_occurred = true;
3079 implementation_started = true;
3080 if !is_error {
3082 let path = res.args.get("path").and_then(|v| v.as_str()).unwrap_or("");
3083 if !path.is_empty() {
3084 self.vein.bump_heat(path);
3085 self.l1_context = self.vein.l1_context();
3086 }
3087 self.refresh_repo_map();
3089 }
3090 }
3091
3092 if tool_name == "verify_build" {
3093 self.record_session_verification(
3094 !is_error
3095 && (final_output.contains("BUILD OK")
3096 || final_output.contains("BUILD SUCCESS")
3097 || final_output.contains("BUILD OKAY")),
3098 if is_error {
3099 "Explicit verify_build failed."
3100 } else {
3101 "Explicit verify_build passed."
3102 },
3103 );
3104 }
3105
3106 let call_key = format!(
3108 "{}:{}",
3109 tool_name,
3110 serde_json::to_string(&res.args).unwrap_or_default()
3111 );
3112 let repeat_count = repeat_counts.entry(call_key.clone()).or_insert(0);
3113 *repeat_count += 1;
3114
3115 let repeat_guard_exempt = matches!(
3117 tool_name.as_str(),
3118 "verify_build" | "git_commit" | "git_push"
3119 );
3120 if *repeat_count >= 3 && !repeat_guard_exempt {
3121 loop_intervention = Some(format!(
3122 "STOP. You have called `{}` with identical arguments {} times and keep getting the same result. \
3123 Do not call it again. Either answer directly from what you already know, \
3124 use a different tool or approach, or ask the user for clarification.",
3125 tool_name, *repeat_count
3126 ));
3127 let _ = tx
3128 .send(InferenceEvent::Thought(format!(
3129 "Repeat guard: `{}` called {} times with same args — injecting stop intervention.",
3130 tool_name, *repeat_count
3131 )))
3132 .await;
3133 }
3134
3135 if is_error {
3136 consecutive_errors += 1;
3137 } else {
3138 consecutive_errors = 0;
3139 }
3140
3141 if consecutive_errors >= 3 {
3142 loop_intervention = Some(
3143 "CRITICAL: Repeated tool failures detected. You are likely stuck in a loop. \
3144 STOP all tool calls immediately. Analyze why your previous 3 calls failed \
3145 (check for hallucinations or invalid arguments) and ask the user for \
3146 clarification if you cannot proceed.".to_string()
3147 );
3148 }
3149
3150 if consecutive_errors >= 4 {
3151 self.emit_runtime_failure(
3152 &tx,
3153 RuntimeFailureClass::ToolLoop,
3154 "Hard termination: too many consecutive tool errors.",
3155 )
3156 .await;
3157 return Ok(());
3158 }
3159
3160 let _ = tx
3161 .send(InferenceEvent::ToolCallResult {
3162 id: call_id.clone(),
3163 name: tool_name.clone(),
3164 output: final_output.clone(),
3165 is_error,
3166 })
3167 .await;
3168
3169 let repeat_guard_exempt = matches!(
3170 tool_name.as_str(),
3171 "verify_build" | "git_commit" | "git_push"
3172 );
3173 if !repeat_guard_exempt {
3174 completed_tool_cache.insert(
3175 canonical_tool_call_key(&tool_name, &res.args),
3176 CachedToolResult {
3177 tool_name: tool_name.clone(),
3178 },
3179 );
3180 }
3181
3182 let compact_ctx = crate::agent::inference::is_compact_context_window_pub(
3184 self.engine.current_context_length(),
3185 );
3186 let capped = if implement_current_plan {
3187 cap_output(&final_output, 1200)
3188 } else if compact_ctx
3189 && (tool_name == "read_file" || tool_name == "inspect_lines")
3190 {
3191 let limit = 3000usize;
3193 if final_output.len() > limit {
3194 let total_lines = final_output.lines().count();
3195 let mut split_at = limit;
3196 while !final_output.is_char_boundary(split_at) && split_at > 0 {
3197 split_at -= 1;
3198 }
3199 let scratch = write_output_to_scratch(&final_output, &tool_name)
3200 .map(|p| format!(" Full file also saved to '{p}'."))
3201 .unwrap_or_default();
3202 format!(
3203 "{}\n... [file truncated — {} total lines. Use `inspect_lines` with start_line near {} to reach the end of the file.{}]",
3204 &final_output[..split_at],
3205 total_lines,
3206 total_lines.saturating_sub(150),
3207 scratch,
3208 )
3209 } else {
3210 final_output.clone()
3211 }
3212 } else {
3213 cap_output_for_tool(&final_output, 8000, &tool_name)
3214 };
3215 self.history.push(ChatMessage::tool_result_for_model(
3216 &call_id,
3217 &tool_name,
3218 &capped,
3219 &self.engine.current_model(),
3220 ));
3221
3222 if architecture_overview_mode && !is_error && tool_name == "trace_runtime_flow"
3223 {
3224 overview_runtime_trace =
3225 Some(summarize_runtime_trace_output(&final_output));
3226 }
3227
3228 if !architecture_overview_mode
3229 && !is_error
3230 && ((grounded_trace_mode && tool_name == "trace_runtime_flow")
3231 || (toolchain_mode && tool_name == "describe_toolchain"))
3232 {
3233 authoritative_tool_output = Some(final_output.clone());
3234 }
3235
3236 if !is_error && tool_name == "read_file" {
3237 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
3238 let normalized = normalize_workspace_path(path);
3239 let read_offset =
3240 res.args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
3241 successful_read_targets.insert(normalized.clone());
3242 successful_read_regions.insert((normalized.clone(), read_offset));
3243 }
3244 }
3245
3246 if !is_error && tool_name == "grep_files" {
3247 if let Some(path) = res.args.get("path").and_then(|v| v.as_str()) {
3248 let normalized = normalize_workspace_path(path);
3249 if final_output.starts_with("No matches for ") {
3250 no_match_grep_targets.insert(normalized);
3251 } else if grep_output_is_high_fanout(&final_output) {
3252 broad_grep_targets.insert(normalized);
3253 } else {
3254 successful_grep_targets.insert(normalized);
3255 }
3256 }
3257 }
3258
3259 if is_error
3260 && matches!(tool_name.as_str(), "edit_file" | "multi_search_replace")
3261 && (final_output.contains("search string not found")
3262 || final_output.contains("search string is too short")
3263 || final_output.contains("search string matched"))
3264 {
3265 if let Some(target) = action_target_path(&tool_name, &res.args) {
3266 let guidance = if final_output.contains("matched") {
3267 format!(
3268 "STOP. `{}` on `{}` — search string matched multiple times. Use `inspect_lines` on the exact region to get a unique anchor, then retry.",
3269 tool_name, target
3270 )
3271 } else {
3272 format!(
3273 "STOP. `{}` on `{}` — search string did not match. Use `inspect_lines` on the target region to get the exact current text (check whitespace and indentation), then retry.",
3274 tool_name, target
3275 )
3276 };
3277 loop_intervention = Some(guidance);
3278 *repeat_count = 0;
3279 }
3280 }
3281
3282 if is_error
3285 && tool_name == "shell"
3286 && final_output.contains("Use the run_code tool instead")
3287 && loop_intervention.is_none()
3288 {
3289 loop_intervention = Some(
3290 "STOP. Shell was blocked because this is a computation task. \
3291 You MUST use `run_code` now — write the code and run it. \
3292 Do NOT output an error message or give up. \
3293 Call `run_code` with the appropriate language and code to compute the answer. \
3294 If writing Python, pass `language: \"python\"`. \
3295 If writing JavaScript, omit language or pass `language: \"javascript\"`."
3296 .to_string(),
3297 );
3298 }
3299
3300 if is_error
3303 && tool_name == "run_code"
3304 && (final_output.contains("source code could not be parsed")
3305 || final_output.contains("Expected ';'")
3306 || final_output.contains("Expected '}'")
3307 || final_output.contains("is not defined")
3308 && final_output.contains("deno"))
3309 && loop_intervention.is_none()
3310 {
3311 loop_intervention = Some(
3312 "STOP. run_code failed with a JavaScript parse error — you likely wrote Python \
3313 code but forgot to pass `language: \"python\"`. \
3314 Retry run_code with `language: \"python\"` and the same code. \
3315 Do NOT fall back to shell. Do NOT give up."
3316 .to_string(),
3317 );
3318 }
3319
3320 if res.blocked_by_policy
3321 && is_mcp_workspace_read_tool(&tool_name)
3322 && recoverable_policy_intervention.is_none()
3323 {
3324 recoverable_policy_intervention = Some(
3325 "STOP. MCP filesystem reads are blocked. Use `read_file` or `inspect_lines` instead.".to_string(),
3326 );
3327 recoverable_policy_recipe = Some(RecoveryScenario::McpWorkspaceReadBlocked);
3328 recoverable_policy_checkpoint = Some((
3329 OperatorCheckpointState::BlockedPolicy,
3330 "MCP workspace read blocked; rerouting to built-in file tools."
3331 .to_string(),
3332 ));
3333 } else if res.blocked_by_policy
3334 && implement_current_plan
3335 && is_current_plan_irrelevant_tool(&tool_name)
3336 && recoverable_policy_intervention.is_none()
3337 {
3338 recoverable_policy_intervention = Some(format!(
3339 "STOP. `{}` is not a planned target. Use `inspect_lines` on a planned file, then edit.",
3340 tool_name
3341 ));
3342 recoverable_policy_recipe = Some(RecoveryScenario::CurrentPlanScopeBlocked);
3343 recoverable_policy_checkpoint = Some((
3344 OperatorCheckpointState::BlockedPolicy,
3345 format!(
3346 "Current-plan execution blocked unrelated tool `{}`.",
3347 tool_name
3348 ),
3349 ));
3350 } else if res.blocked_by_policy
3351 && implement_current_plan
3352 && final_output.contains("requires recent file evidence")
3353 && recoverable_policy_intervention.is_none()
3354 {
3355 let target = action_target_path(&tool_name, &res.args)
3356 .unwrap_or_else(|| "the target file".to_string());
3357 recoverable_policy_intervention = Some(format!(
3358 "STOP. Edit blocked — `{target}` has no recent read. Use `inspect_lines` or `read_file` on it first, then retry."
3359 ));
3360 recoverable_policy_recipe =
3361 Some(RecoveryScenario::RecentFileEvidenceMissing);
3362 recoverable_policy_checkpoint = Some((
3363 OperatorCheckpointState::BlockedRecentFileEvidence,
3364 format!("Edit blocked on `{target}`; recent file evidence missing."),
3365 ));
3366 } else if res.blocked_by_policy
3367 && implement_current_plan
3368 && final_output.contains("requires an exact local line window first")
3369 && recoverable_policy_intervention.is_none()
3370 {
3371 let target = action_target_path(&tool_name, &res.args)
3372 .unwrap_or_else(|| "the target file".to_string());
3373 recoverable_policy_intervention = Some(format!(
3374 "STOP. Edit blocked — `{target}` needs an inspected window. Use `inspect_lines` around the edit region, then retry."
3375 ));
3376 recoverable_policy_recipe = Some(RecoveryScenario::ExactLineWindowRequired);
3377 recoverable_policy_checkpoint = Some((
3378 OperatorCheckpointState::BlockedExactLineWindow,
3379 format!("Edit blocked on `{target}`; exact line window required."),
3380 ));
3381 } else if res.blocked_by_policy
3382 && (final_output.contains("Prefer `")
3383 || final_output.contains("Prefer tool"))
3384 && recoverable_policy_intervention.is_none()
3385 {
3386 recoverable_policy_intervention = Some(final_output.clone());
3387 recoverable_policy_recipe = Some(RecoveryScenario::PolicyCorrection);
3388 recoverable_policy_checkpoint = Some((
3389 OperatorCheckpointState::BlockedPolicy,
3390 "Action blocked by policy; self-correction triggered using tool recommendation."
3391 .to_string(),
3392 ));
3393 } else if res.blocked_by_policy && blocked_policy_output.is_none() {
3394 blocked_policy_output = Some(final_output.clone());
3395 }
3396
3397 if *repeat_count >= 5 {
3398 let _ = tx.send(InferenceEvent::Done).await;
3399 return Ok(());
3400 }
3401
3402 if implement_current_plan
3403 && !implementation_started
3404 && !is_error
3405 && is_non_mutating_plan_step_tool(&tool_name)
3406 {
3407 non_mutating_plan_steps += 1;
3408 }
3409 }
3410
3411 if let Some(intervention) = recoverable_policy_intervention {
3412 if let Some((state, summary)) = recoverable_policy_checkpoint.take() {
3413 self.emit_operator_checkpoint(&tx, state, summary).await;
3414 }
3415 if let Some(scenario) = recoverable_policy_recipe.take() {
3416 let recipe = plan_recovery(scenario, &self.recovery_context);
3417 self.emit_recovery_recipe_summary(
3418 &tx,
3419 recipe.recipe.scenario.label(),
3420 compact_recovery_plan_summary(&recipe),
3421 )
3422 .await;
3423 }
3424 loop_intervention = Some(intervention);
3425 let _ = tx
3426 .send(InferenceEvent::Thought(
3427 "Policy recovery: rerouting blocked MCP filesystem inspection to built-in workspace tools."
3428 .into(),
3429 ))
3430 .await;
3431 continue;
3432 }
3433
3434 if architecture_overview_mode {
3435 match overview_runtime_trace.as_deref() {
3436 Some(runtime_trace) => {
3437 let response = build_architecture_overview_answer(runtime_trace);
3438 self.history.push(ChatMessage::assistant_text(&response));
3439 self.transcript.log_agent(&response);
3440
3441 for chunk in chunk_text(&response, 8) {
3442 if !chunk.is_empty() {
3443 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3444 }
3445 }
3446
3447 let _ = tx.send(InferenceEvent::Done).await;
3448 break;
3449 }
3450 None => {
3451 loop_intervention = Some(
3452 "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."
3453 .to_string(),
3454 );
3455 continue;
3456 }
3457 }
3458 }
3459
3460 if implement_current_plan
3461 && !implementation_started
3462 && non_mutating_plan_steps >= non_mutating_plan_hard_cap
3463 {
3464 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();
3465 self.history.push(ChatMessage::assistant_text(&msg));
3466 self.transcript.log_agent(&msg);
3467
3468 for chunk in chunk_text(&msg, 8) {
3469 if !chunk.is_empty() {
3470 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3471 }
3472 }
3473
3474 let _ = tx.send(InferenceEvent::Done).await;
3475 break;
3476 }
3477
3478 if let Some(blocked_output) = blocked_policy_output {
3479 self.emit_operator_checkpoint(
3480 &tx,
3481 OperatorCheckpointState::BlockedPolicy,
3482 "A blocked tool path was surfaced directly to the operator.",
3483 )
3484 .await;
3485 self.history
3486 .push(ChatMessage::assistant_text(&blocked_output));
3487 self.transcript.log_agent(&blocked_output);
3488
3489 for chunk in chunk_text(&blocked_output, 8) {
3490 if !chunk.is_empty() {
3491 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3492 }
3493 }
3494
3495 let _ = tx.send(InferenceEvent::Done).await;
3496 break;
3497 }
3498
3499 if let Some(tool_output) = authoritative_tool_output {
3500 self.history.push(ChatMessage::assistant_text(&tool_output));
3501 self.transcript.log_agent(&tool_output);
3502
3503 for chunk in chunk_text(&tool_output, 8) {
3504 if !chunk.is_empty() {
3505 let _ = tx.send(InferenceEvent::Token(chunk)).await;
3506 }
3507 }
3508
3509 let _ = tx.send(InferenceEvent::Done).await;
3510 break;
3511 }
3512
3513 if implement_current_plan && !implementation_started {
3514 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.";
3515 if non_mutating_plan_steps >= non_mutating_plan_soft_cap {
3516 loop_intervention = Some(format!(
3517 "{} You are close to the non-mutation cap. Use `inspect_lines` on one saved target file, then make the edit now.",
3518 base
3519 ));
3520 } else {
3521 loop_intervention = Some(base.to_string());
3522 }
3523 } else if self.workflow_mode == WorkflowMode::Architect {
3524 loop_intervention = Some(
3525 format!(
3526 "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.",
3527 architect_handoff_contract()
3528 ),
3529 );
3530 }
3531
3532 if mutation_occurred && !yolo {
3534 let _ = tx
3535 .send(InferenceEvent::Thought(
3536 "Self-Verification: Running 'cargo check' to ensure build integrity..."
3537 .into(),
3538 ))
3539 .await;
3540 let verify_res = self.auto_verify_build().await;
3541 let verify_ok = verify_res.contains("BUILD SUCCESS");
3542 self.record_verify_build_result(verify_ok, &verify_res)
3543 .await;
3544 self.record_session_verification(
3545 verify_ok,
3546 if verify_ok {
3547 "Automatic build verification passed."
3548 } else {
3549 "Automatic build verification failed."
3550 },
3551 );
3552 self.history.push(ChatMessage::system(&format!(
3553 "\n# SYSTEM VERIFICATION\n{verify_res}"
3554 )));
3555 let _ = tx
3556 .send(InferenceEvent::Thought(
3557 "Verification turn injected into history.".into(),
3558 ))
3559 .await;
3560 }
3561
3562 continue;
3564 } else if let Some(response_text) = text {
3565 if finish_reason.as_deref() == Some("length") && near_context_ceiling {
3566 if intent.direct_answer == Some(DirectAnswerKind::SessionResetSemantics) {
3567 let cleaned = build_session_reset_semantics_answer();
3568 self.history.push(ChatMessage::assistant_text(&cleaned));
3569 self.transcript.log_agent(&cleaned);
3570 for chunk in chunk_text(&cleaned, 8) {
3571 if !chunk.is_empty() {
3572 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3573 }
3574 }
3575 let _ = tx.send(InferenceEvent::Done).await;
3576 break;
3577 }
3578
3579 let warning = format_runtime_failure(
3580 RuntimeFailureClass::ContextWindow,
3581 "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.",
3582 );
3583 self.history.push(ChatMessage::assistant_text(&warning));
3584 self.transcript.log_agent(&warning);
3585 let _ = tx
3586 .send(InferenceEvent::Thought(
3587 "Length recovery: model hit the context ceiling before completing the answer."
3588 .into(),
3589 ))
3590 .await;
3591 for chunk in chunk_text(&warning, 8) {
3592 if !chunk.is_empty() {
3593 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3594 }
3595 }
3596 let _ = tx.send(InferenceEvent::Done).await;
3597 break;
3598 }
3599
3600 if response_text.contains("<|tool_call")
3601 || response_text.contains("[END_TOOL_REQUEST]")
3602 || response_text.contains("<|tool_response")
3603 || response_text.contains("<tool_response|>")
3604 {
3605 loop_intervention = Some(
3606 "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(),
3607 );
3608 continue;
3609 }
3610
3611 if let Some(thought) = crate::agent::inference::extract_think_block(&response_text)
3613 {
3614 let _ = tx.send(InferenceEvent::Thought(thought.clone())).await;
3615 self.reasoning_history = Some(thought);
3618 }
3619
3620 let cleaned = crate::agent::inference::strip_think_blocks(&response_text);
3622
3623 if implement_current_plan && !implementation_started {
3624 loop_intervention = Some(
3625 "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(),
3626 );
3627 continue;
3628 }
3629
3630 if cleaned.is_empty() {
3636 empty_cleaned_nudges += 1;
3637 if empty_cleaned_nudges == 1 {
3638 loop_intervention = Some(
3639 "Your visible response was empty. The tool already returned data. \
3640 Write your answer now in plain text — no <think> tags, no tool calls. \
3641 State the key facts in 2-5 sentences and stop."
3642 .to_string(),
3643 );
3644 continue;
3645 } else if empty_cleaned_nudges == 2 {
3646 loop_intervention = Some(
3647 "EMPTY RESPONSE. Do NOT use <think>. Do NOT call tools. \
3648 Write the answer in plain text right now. \
3649 Example format: \"Your CPU is X. Your GPU is Y. You have Z GB of RAM.\""
3650 .to_string(),
3651 );
3652 continue;
3653 }
3654 let class = RuntimeFailureClass::EmptyModelResponse;
3657 self.emit_runtime_failure(
3658 &tx,
3659 class,
3660 "Model returned empty content after 2 nudge attempts.",
3661 )
3662 .await;
3663 break;
3664 }
3665
3666 let architect_handoff = self.persist_architect_handoff(&cleaned);
3667 self.history.push(ChatMessage::assistant_text(&cleaned));
3668 self.transcript.log_agent(&cleaned);
3669
3670 for chunk in chunk_text(&cleaned, 8) {
3672 if !chunk.is_empty() {
3673 let _ = tx.send(InferenceEvent::Token(chunk.clone())).await;
3674 }
3675 }
3676
3677 if let Some(plan) = architect_handoff.as_ref() {
3678 let note = architect_handoff_operator_note(plan);
3679 self.history.push(ChatMessage::system(¬e));
3680 self.transcript.log_system(¬e);
3681 let _ = tx
3682 .send(InferenceEvent::MutedToken(format!("\n{}", note)))
3683 .await;
3684 }
3685
3686 let _ = tx.send(InferenceEvent::Done).await;
3687 break;
3688 } else {
3689 let detail = "Model returned an empty response.";
3690 let class = classify_runtime_failure(detail);
3691 if should_retry_runtime_failure(class) {
3692 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
3693 if let RecoveryDecision::Attempt(plan) =
3694 attempt_recovery(scenario, &mut self.recovery_context)
3695 {
3696 self.transcript.log_system(
3697 "Automatic provider recovery triggered: model returned an empty response.",
3698 );
3699 self.emit_recovery_recipe_summary(
3700 &tx,
3701 plan.recipe.scenario.label(),
3702 compact_recovery_plan_summary(&plan),
3703 )
3704 .await;
3705 let _ = tx
3706 .send(InferenceEvent::ProviderStatus {
3707 state: ProviderRuntimeState::Recovering,
3708 summary: compact_runtime_recovery_summary(class).into(),
3709 })
3710 .await;
3711 self.emit_operator_checkpoint(
3712 &tx,
3713 OperatorCheckpointState::RecoveringProvider,
3714 compact_runtime_recovery_summary(class),
3715 )
3716 .await;
3717 continue;
3718 }
3719 }
3720 }
3721
3722 self.emit_runtime_failure(&tx, class, detail).await;
3723 break;
3724 }
3725 }
3726
3727 self.trim_history(80);
3728 self.refresh_session_memory();
3729 self.last_goal = Some(user_input.chars().take(300).collect());
3731 self.turn_count = self.turn_count.saturating_add(1);
3732 self.save_session();
3733 self.emit_compaction_pressure(&tx).await;
3734 Ok(())
3735 }
3736
3737 async fn emit_runtime_failure(
3738 &mut self,
3739 tx: &mpsc::Sender<InferenceEvent>,
3740 class: RuntimeFailureClass,
3741 detail: &str,
3742 ) {
3743 if let Some(scenario) = recovery_scenario_for_runtime_failure(class) {
3744 let decision = preview_recovery_decision(scenario, &self.recovery_context);
3745 self.emit_recovery_recipe_summary(
3746 tx,
3747 scenario.label(),
3748 compact_recovery_decision_summary(&decision),
3749 )
3750 .await;
3751 let needs_refresh = match &decision {
3752 RecoveryDecision::Attempt(plan) => plan
3753 .recipe
3754 .steps
3755 .contains(&RecoveryStep::RefreshRuntimeProfile),
3756 RecoveryDecision::Escalate { recipe, .. } => {
3757 recipe.steps.contains(&RecoveryStep::RefreshRuntimeProfile)
3758 }
3759 };
3760 if needs_refresh {
3761 if let Some((model_id, context_length, changed)) = self
3762 .refresh_runtime_profile_and_report(tx, "context_window_failure")
3763 .await
3764 {
3765 let note = if changed {
3766 format!(
3767 "Runtime refresh after context-window failure: model {} | CTX {}",
3768 model_id, context_length
3769 )
3770 } else {
3771 format!(
3772 "Runtime refresh after context-window failure confirms model {} | CTX {}",
3773 model_id, context_length
3774 )
3775 };
3776 let _ = tx.send(InferenceEvent::Thought(note)).await;
3777 }
3778 }
3779 }
3780 if let Some(state) = provider_state_for_runtime_failure(class) {
3781 let _ = tx
3782 .send(InferenceEvent::ProviderStatus {
3783 state,
3784 summary: compact_runtime_failure_summary(class).into(),
3785 })
3786 .await;
3787 }
3788 if let Some(state) = checkpoint_state_for_runtime_failure(class) {
3789 self.emit_operator_checkpoint(tx, state, checkpoint_summary_for_runtime_failure(class))
3790 .await;
3791 }
3792 let formatted = format_runtime_failure(class, detail);
3793 self.history.push(ChatMessage::system(&format!(
3794 "# RUNTIME FAILURE\n{}",
3795 formatted
3796 )));
3797 self.transcript.log_system(&formatted);
3798 let _ = tx.send(InferenceEvent::Error(formatted)).await;
3799 let _ = tx.send(InferenceEvent::Done).await;
3800 }
3801
3802 async fn auto_verify_build(&self) -> String {
3804 match crate::tools::verify_build::execute(&serde_json::json!({ "action": "build" })).await {
3805 Ok(out) => {
3806 "BUILD SUCCESS: Your changes are architecturally sound.\n\n".to_string()
3807 + &cap_output(&out, 2000)
3808 }
3809 Err(e) => format!(
3810 "BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:\n\n{}",
3811 cap_output(&e, 2000)
3812 ),
3813 }
3814 }
3815
3816 async fn compact_history_if_needed(
3820 &mut self,
3821 tx: &mpsc::Sender<InferenceEvent>,
3822 anchor_index: Option<usize>,
3823 ) -> Result<bool, String> {
3824 let vram_ratio = self.gpu_state.ratio();
3825 let context_length = self.engine.current_context_length();
3826 let config = CompactionConfig::adaptive(context_length, vram_ratio);
3827
3828 if !compaction::should_compact(&self.history, context_length, vram_ratio) {
3829 return Ok(false);
3830 }
3831
3832 let _ = tx
3833 .send(InferenceEvent::Thought(format!(
3834 "Compaction: ctx={}k vram={:.0}% threshold={}k tokens — chaining summary...",
3835 context_length / 1000,
3836 vram_ratio * 100.0,
3837 config.max_estimated_tokens / 1000,
3838 )))
3839 .await;
3840
3841 let result = compaction::compact_history(
3842 &self.history,
3843 self.running_summary.as_deref(),
3844 config,
3845 anchor_index,
3846 );
3847
3848 let removed_message_count = self.history.len().saturating_sub(result.messages.len());
3849 self.history = result.messages;
3850 self.running_summary = result.summary;
3851
3852 let previous_memory = self.session_memory.clone();
3854 self.session_memory = compaction::extract_memory(&self.history);
3855 self.session_memory
3856 .inherit_runtime_ledger_from(&previous_memory);
3857 self.session_memory.record_compaction(
3858 removed_message_count,
3859 format!(
3860 "Compacted history around active task '{}' and preserved {} working-set file(s).",
3861 self.session_memory.current_task,
3862 self.session_memory.working_set.len()
3863 ),
3864 );
3865 self.emit_compaction_pressure(tx).await;
3866
3867 let first_non_sys = self
3870 .history
3871 .iter()
3872 .position(|m| m.role != "system")
3873 .unwrap_or(self.history.len());
3874 if first_non_sys < self.history.len() {
3875 if let Some(user_offset) = self.history[first_non_sys..]
3876 .iter()
3877 .position(|m| m.role == "user")
3878 {
3879 if user_offset > 0 {
3880 self.history
3881 .drain(first_non_sys..first_non_sys + user_offset);
3882 }
3883 }
3884 }
3885
3886 let _ = tx
3887 .send(InferenceEvent::Thought(format!(
3888 "Memory Synthesis: Extracted context for task: '{}'. Working set: {} files.",
3889 self.session_memory.current_task,
3890 self.session_memory.working_set.len()
3891 )))
3892 .await;
3893 let recipe = plan_recovery(RecoveryScenario::HistoryPressure, &self.recovery_context);
3894 self.emit_recovery_recipe_summary(
3895 tx,
3896 recipe.recipe.scenario.label(),
3897 compact_recovery_plan_summary(&recipe),
3898 )
3899 .await;
3900 self.emit_operator_checkpoint(
3901 tx,
3902 OperatorCheckpointState::HistoryCompacted,
3903 format!(
3904 "History compacted into a recursive summary; active task '{}' with {} working-set file(s) carried forward.",
3905 self.session_memory.current_task,
3906 self.session_memory.working_set.len()
3907 ),
3908 )
3909 .await;
3910
3911 Ok(true)
3912 }
3913
3914 fn build_vein_context(&self, query: &str) -> Option<(String, Vec<String>)> {
3918 if query.trim().split_whitespace().count() < 3 {
3920 return None;
3921 }
3922
3923 let results = tokio::task::block_in_place(|| self.vein.search_context(query, 4)).ok()?;
3924 if results.is_empty() {
3925 return None;
3926 }
3927
3928 let semantic_active = self.vein.has_any_embeddings();
3929 let header = if semantic_active {
3930 "# Relevant context from The Vein (hybrid BM25 + semantic retrieval)\n\
3931 Use this to answer without needing extra read_file calls where possible.\n\n"
3932 } else {
3933 "# Relevant context from The Vein (BM25 keyword retrieval)\n\
3934 Use this to answer without needing extra read_file calls where possible.\n\n"
3935 };
3936
3937 let mut ctx = String::from(header);
3938 let mut paths: Vec<String> = Vec::new();
3939
3940 let mut total = 0usize;
3941 const MAX_CTX_CHARS: usize = 1_500;
3942
3943 for r in results {
3944 if total >= MAX_CTX_CHARS {
3945 break;
3946 }
3947 let snippet = if r.content.len() > 500 {
3948 format!("{}...", &r.content[..500])
3949 } else {
3950 r.content.clone()
3951 };
3952 ctx.push_str(&format!("--- {} ---\n{}\n\n", r.path, snippet));
3953 total += snippet.len() + r.path.len() + 10;
3954 if !paths.contains(&r.path) {
3955 paths.push(r.path);
3956 }
3957 }
3958
3959 Some((ctx, paths))
3960 }
3961
3962 fn context_window_slice(&self) -> Vec<ChatMessage> {
3965 let mut result = Vec::new();
3966
3967 if self.history.len() > 1 {
3969 for m in &self.history[1..] {
3970 if m.role == "system" {
3971 continue;
3972 }
3973
3974 let mut sanitized = m.clone();
3975 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
3977 sanitized.content = MessageContent::Text(" ".into());
3978 }
3979 result.push(sanitized);
3980 }
3981 }
3982
3983 if !result.is_empty() && result[0].role != "user" {
3986 result.insert(0, ChatMessage::user("Continuing previous context..."));
3987 }
3988
3989 result
3990 }
3991
3992 fn context_window_slice_from(&self, start_idx: usize) -> Vec<ChatMessage> {
3993 let mut result = Vec::new();
3994
3995 if self.history.len() > 1 {
3996 let start = start_idx.max(1).min(self.history.len());
3997 for m in &self.history[start..] {
3998 if m.role == "system" {
3999 continue;
4000 }
4001
4002 let mut sanitized = m.clone();
4003 if (m.role == "assistant" || m.role == "tool") && m.content.as_str().is_empty() {
4004 sanitized.content = MessageContent::Text(" ".into());
4005 }
4006 result.push(sanitized);
4007 }
4008 }
4009
4010 if !result.is_empty() && result[0].role != "user" {
4011 result.insert(0, ChatMessage::user("Continuing current plan execution..."));
4012 }
4013
4014 result
4015 }
4016
4017 fn trim_history(&mut self, max_messages: usize) {
4019 if self.history.len() <= max_messages {
4020 return;
4021 }
4022 let excess = self.history.len() - max_messages;
4024 self.history.drain(1..=excess);
4025 }
4026
4027 async fn repair_tool_args(
4029 &self,
4030 tool_name: &str,
4031 bad_json: &str,
4032 tx: &mpsc::Sender<InferenceEvent>,
4033 ) -> Result<Value, String> {
4034 let _ = tx
4035 .send(InferenceEvent::Thought(format!(
4036 "Attempting to repair malformed JSON for '{}'...",
4037 tool_name
4038 )))
4039 .await;
4040
4041 let prompt = format!(
4042 "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.",
4043 tool_name, bad_json
4044 );
4045
4046 let messages = vec![
4047 ChatMessage::system("You are a JSON repair tool. Output ONLY pure JSON."),
4048 ChatMessage::user(&prompt),
4049 ];
4050
4051 let (text, _, _, _) = self
4053 .engine
4054 .call_with_tools(&messages, &[], self.fast_model.as_deref())
4055 .await
4056 .map_err(|e| e.to_string())?;
4057
4058 let cleaned = text
4059 .unwrap_or_default()
4060 .trim()
4061 .trim_start_matches("```json")
4062 .trim_start_matches("```")
4063 .trim_end_matches("```")
4064 .trim()
4065 .to_string();
4066
4067 serde_json::from_str(&cleaned).map_err(|e| format!("Repair failed: {}", e))
4068 }
4069
4070 async fn run_critic_check(
4072 &self,
4073 path: &str,
4074 content: &str,
4075 tx: &mpsc::Sender<InferenceEvent>,
4076 ) -> Option<String> {
4077 let ext = std::path::Path::new(path)
4079 .extension()
4080 .and_then(|e| e.to_str())
4081 .unwrap_or("");
4082 const CRITIC_EXTS: &[&str] = &["rs", "js", "ts", "py", "go", "c", "cpp"];
4083 if !CRITIC_EXTS.contains(&ext) {
4084 return None;
4085 }
4086
4087 let _ = tx
4088 .send(InferenceEvent::Thought(format!(
4089 "CRITIC: Reviewing changes to '{}'...",
4090 path
4091 )))
4092 .await;
4093
4094 let truncated = cap_output(content, 4000);
4095
4096 let prompt = format!(
4097 "You are a Senior Security and Code Quality auditor. Review this file content for '{}' and identify any critical logic errors, security vulnerabilities, or missing error handling. Be extremely concise. If the code looks good, output 'PASS'.\n\n```{}\n{}\n```",
4098 path, ext, truncated
4099 );
4100
4101 let messages = vec![
4102 ChatMessage::system("You are a technical critic. Identify ONLY critical issues. Output 'PASS' if none found."),
4103 ChatMessage::user(&prompt)
4104 ];
4105
4106 let (text, _, _, _) = self
4107 .engine
4108 .call_with_tools(&messages, &[], self.fast_model.as_deref())
4109 .await
4110 .ok()?;
4111
4112 let critique = text?.trim().to_string();
4113 if critique.to_uppercase().contains("PASS") || critique.is_empty() {
4114 None
4115 } else {
4116 Some(critique)
4117 }
4118 }
4119}
4120
4121pub async fn dispatch_tool(name: &str, args: &Value) -> Result<String, String> {
4124 dispatch_builtin_tool(name, args).await
4125}
4126
4127fn normalize_fix_plan_issue_text(text: &str) -> Option<String> {
4128 let trimmed = text.trim();
4129 let stripped = trimmed
4130 .strip_prefix("/think")
4131 .or_else(|| trimmed.strip_prefix("/no_think"))
4132 .map(str::trim)
4133 .unwrap_or(trimmed)
4134 .trim_start_matches('\n')
4135 .trim();
4136 (!stripped.is_empty()).then(|| stripped.to_string())
4137}
4138
4139fn fill_missing_fix_plan_issue(tool_name: &str, args: &mut Value, fallback_issue: Option<&str>) {
4140 if tool_name != "inspect_host" {
4141 return;
4142 }
4143
4144 let Some(topic) = args.get("topic").and_then(|v| v.as_str()) else {
4145 return;
4146 };
4147 if topic != "fix_plan" {
4148 return;
4149 }
4150
4151 let issue_missing = args
4152 .get("issue")
4153 .and_then(|v| v.as_str())
4154 .map(str::trim)
4155 .is_none_or(|value| value.is_empty());
4156 if !issue_missing {
4157 return;
4158 }
4159
4160 let Some(fallback_issue) = fallback_issue.and_then(normalize_fix_plan_issue_text) else {
4161 return;
4162 };
4163
4164 let Value::Object(map) = args else {
4165 return;
4166 };
4167 map.insert(
4168 "issue".to_string(),
4169 Value::String(fallback_issue.to_string()),
4170 );
4171}
4172
4173fn should_rewrite_shell_to_fix_plan(
4174 tool_name: &str,
4175 args: &Value,
4176 latest_user_prompt: Option<&str>,
4177) -> bool {
4178 if tool_name != "shell" {
4179 return false;
4180 }
4181 let Some(prompt) = latest_user_prompt else {
4182 return false;
4183 };
4184 if preferred_host_inspection_topic(prompt) != Some("fix_plan") {
4185 return false;
4186 }
4187 let command = args
4188 .get("command")
4189 .and_then(|value| value.as_str())
4190 .unwrap_or("");
4191 shell_looks_like_structured_host_inspection(command)
4192}
4193
4194fn extract_release_arg(command: &str, flag: &str) -> Option<String> {
4195 let pattern = format!(r#"(?i){}\s+['"]?([^'" \r\n]+)['"]?"#, regex::escape(flag));
4196 let regex = regex::Regex::new(&pattern).ok()?;
4197 let captures = regex.captures(command)?;
4198 captures.get(1).map(|m| m.as_str().to_string())
4199}
4200
4201fn infer_maintainer_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
4202 let workflow = preferred_maintainer_workflow(prompt)?;
4203 let lower = prompt.to_ascii_lowercase();
4204 match workflow {
4205 "clean" => Some(serde_json::json!({
4206 "workflow": "clean",
4207 "deep": lower.contains("deep clean")
4208 || lower.contains("deep cleanup")
4209 || lower.contains("deep"),
4210 "reset": lower.contains("reset"),
4211 "prune_dist": lower.contains("prune dist")
4212 || lower.contains("prune old dist")
4213 || lower.contains("prune old artifacts")
4214 || lower.contains("old dist artifacts")
4215 || lower.contains("old artifacts"),
4216 })),
4217 "package_windows" => Some(serde_json::json!({
4218 "workflow": "package_windows",
4219 "installer": lower.contains("installer") || lower.contains("setup.exe"),
4220 "add_to_path": lower.contains("addtopath")
4221 || lower.contains("add to path")
4222 || lower.contains("update path")
4223 || lower.contains("refresh path"),
4224 })),
4225 "release" => {
4226 let version = regex::Regex::new(r#"(?i)\b(\d+\.\d+\.\d+)\b"#)
4227 .ok()
4228 .and_then(|re| re.captures(prompt))
4229 .and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()));
4230 let bump = if lower.contains("patch") {
4231 Some("patch")
4232 } else if lower.contains("minor") {
4233 Some("minor")
4234 } else if lower.contains("major") {
4235 Some("major")
4236 } else {
4237 None
4238 };
4239 let mut args = serde_json::json!({
4240 "workflow": "release",
4241 "push": lower.contains(" push") || lower.starts_with("push ") || lower.contains(" and push"),
4242 "add_to_path": lower.contains("addtopath")
4243 || lower.contains("add to path")
4244 || lower.contains("update path"),
4245 "skip_installer": lower.contains("skip installer"),
4246 "publish_crates": lower.contains("publish crates") || lower.contains("crates.io"),
4247 "publish_voice_crate": lower.contains("publish voice crate")
4248 || lower.contains("publish hematite-kokoros"),
4249 });
4250 if let Some(version) = version {
4251 args["version"] = Value::String(version);
4252 }
4253 if let Some(bump) = bump {
4254 args["bump"] = Value::String(bump.to_string());
4255 }
4256 Some(args)
4257 }
4258 _ => None,
4259 }
4260}
4261
4262fn infer_workspace_workflow_args_from_prompt(prompt: &str) -> Option<Value> {
4263 let workflow = preferred_workspace_workflow(prompt)?;
4264 let lower = prompt.to_ascii_lowercase();
4265 let trimmed = prompt.trim();
4266
4267 if let Some(command) = extract_workspace_command_from_prompt(trimmed) {
4268 return Some(serde_json::json!({
4269 "workflow": "command",
4270 "command": command,
4271 }));
4272 }
4273
4274 if let Some(path) = extract_workspace_script_path_from_prompt(trimmed) {
4275 return Some(serde_json::json!({
4276 "workflow": "script_path",
4277 "path": path,
4278 }));
4279 }
4280
4281 match workflow {
4282 "build" | "test" | "lint" | "fix" => Some(serde_json::json!({
4283 "workflow": workflow,
4284 })),
4285 "script" => {
4286 let package_script = if lower.contains("npm run ") {
4287 extract_word_after(&lower, "npm run ")
4288 } else if lower.contains("pnpm run ") {
4289 extract_word_after(&lower, "pnpm run ")
4290 } else if lower.contains("bun run ") {
4291 extract_word_after(&lower, "bun run ")
4292 } else if lower.contains("yarn ") {
4293 extract_word_after(&lower, "yarn ")
4294 } else {
4295 None
4296 };
4297
4298 if let Some(name) = package_script {
4299 return Some(serde_json::json!({
4300 "workflow": "package_script",
4301 "name": name,
4302 }));
4303 }
4304
4305 if let Some(name) = extract_word_after(&lower, "just ") {
4306 return Some(serde_json::json!({
4307 "workflow": "just",
4308 "name": name,
4309 }));
4310 }
4311 if let Some(name) = extract_word_after(&lower, "make ") {
4312 return Some(serde_json::json!({
4313 "workflow": "make",
4314 "name": name,
4315 }));
4316 }
4317 if let Some(name) = extract_word_after(&lower, "task ") {
4318 return Some(serde_json::json!({
4319 "workflow": "task",
4320 "name": name,
4321 }));
4322 }
4323
4324 None
4325 }
4326 _ => None,
4327 }
4328}
4329
4330fn extract_workspace_command_from_prompt(prompt: &str) -> Option<String> {
4331 let lower = prompt.to_ascii_lowercase();
4332 for prefix in [
4333 "cargo ",
4334 "npm ",
4335 "pnpm ",
4336 "yarn ",
4337 "bun ",
4338 "pytest",
4339 "go build",
4340 "go test",
4341 "make ",
4342 "just ",
4343 "task ",
4344 "./gradlew",
4345 ".\\gradlew",
4346 ] {
4347 if let Some(index) = lower.find(prefix) {
4348 return Some(prompt[index..].trim().trim_matches('`').to_string());
4349 }
4350 }
4351 None
4352}
4353
4354fn extract_workspace_script_path_from_prompt(prompt: &str) -> Option<String> {
4355 let normalized = prompt.replace('\\', "/");
4356 for token in normalized.split_whitespace() {
4357 let candidate = token
4358 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
4359 .trim_start_matches("./");
4360 if candidate.starts_with("scripts/")
4361 && [".ps1", ".sh", ".py", ".cmd", ".bat", ".js", ".mjs", ".cjs"]
4362 .iter()
4363 .any(|ext| candidate.to_ascii_lowercase().ends_with(ext))
4364 {
4365 return Some(candidate.to_string());
4366 }
4367 }
4368 None
4369}
4370
4371fn extract_word_after(haystack: &str, prefix: &str) -> Option<String> {
4372 let start = haystack.find(prefix)? + prefix.len();
4373 let tail = &haystack[start..];
4374 let word = tail
4375 .split_whitespace()
4376 .next()
4377 .map(str::trim)
4378 .filter(|value| !value.is_empty())?;
4379 Some(
4380 word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | '.' | ')' | '('))
4381 .to_string(),
4382 )
4383}
4384
4385fn rewrite_shell_to_maintainer_workflow_args(command: &str) -> Option<Value> {
4386 let lower = command.to_ascii_lowercase();
4387 if lower.contains("clean.ps1") {
4388 return Some(serde_json::json!({
4389 "workflow": "clean",
4390 "deep": lower.contains("-deep"),
4391 "reset": lower.contains("-reset"),
4392 "prune_dist": lower.contains("-prunedist"),
4393 }));
4394 }
4395 if lower.contains("package-windows.ps1") {
4396 return Some(serde_json::json!({
4397 "workflow": "package_windows",
4398 "installer": lower.contains("-installer"),
4399 "add_to_path": lower.contains("-addtopath"),
4400 }));
4401 }
4402 if lower.contains("release.ps1") {
4403 let version = extract_release_arg(command, "-Version");
4404 let bump = extract_release_arg(command, "-Bump");
4405 if version.is_none() && bump.is_none() {
4406 return Some(serde_json::json!({
4407 "workflow": "release"
4408 }));
4409 }
4410 let mut args = serde_json::json!({
4411 "workflow": "release",
4412 "push": lower.contains("-push"),
4413 "add_to_path": lower.contains("-addtopath"),
4414 "skip_installer": lower.contains("-skipinstaller"),
4415 "publish_crates": lower.contains("-publishcrates"),
4416 "publish_voice_crate": lower.contains("-publishvoicecrate"),
4417 });
4418 if let Some(version) = version {
4419 args["version"] = Value::String(version);
4420 }
4421 if let Some(bump) = bump {
4422 args["bump"] = Value::String(bump);
4423 }
4424 return Some(args);
4425 }
4426 None
4427}
4428
4429fn rewrite_shell_to_workspace_workflow_args(command: &str) -> Option<Value> {
4430 let lower = command.to_ascii_lowercase();
4431 if lower.contains("clean.ps1")
4432 || lower.contains("package-windows.ps1")
4433 || lower.contains("release.ps1")
4434 {
4435 return None;
4436 }
4437
4438 if let Some(path) = extract_workspace_script_path_from_prompt(command) {
4439 return Some(serde_json::json!({
4440 "workflow": "script_path",
4441 "path": path,
4442 }));
4443 }
4444
4445 let looks_like_workspace_command = [
4446 "cargo ",
4447 "npm ",
4448 "pnpm ",
4449 "yarn ",
4450 "bun ",
4451 "pytest",
4452 "go build",
4453 "go test",
4454 "make ",
4455 "just ",
4456 "task ",
4457 "./gradlew",
4458 ".\\gradlew",
4459 ]
4460 .iter()
4461 .any(|needle| lower.contains(needle));
4462
4463 if looks_like_workspace_command {
4464 Some(serde_json::json!({
4465 "workflow": "command",
4466 "command": command.trim(),
4467 }))
4468 } else {
4469 None
4470 }
4471}
4472
4473fn rewrite_host_tool_call(
4474 tool_name: &mut String,
4475 args: &mut Value,
4476 latest_user_prompt: Option<&str>,
4477) {
4478 if *tool_name == "shell" {
4479 let command = args
4480 .get("command")
4481 .and_then(|value| value.as_str())
4482 .unwrap_or("");
4483 if let Some(maintainer_workflow_args) = rewrite_shell_to_maintainer_workflow_args(command) {
4484 *tool_name = "run_hematite_maintainer_workflow".to_string();
4485 *args = maintainer_workflow_args;
4486 return;
4487 }
4488 if let Some(workspace_workflow_args) = rewrite_shell_to_workspace_workflow_args(command) {
4489 *tool_name = "run_workspace_workflow".to_string();
4490 *args = workspace_workflow_args;
4491 return;
4492 }
4493 }
4494 if *tool_name != "run_hematite_maintainer_workflow" {
4495 if let Some(prompt_args) =
4496 latest_user_prompt.and_then(infer_maintainer_workflow_args_from_prompt)
4497 {
4498 *tool_name = "run_hematite_maintainer_workflow".to_string();
4499 *args = prompt_args;
4500 return;
4501 }
4502 }
4503 if *tool_name != "run_workspace_workflow" {
4504 if let Some(prompt_args) =
4505 latest_user_prompt.and_then(infer_workspace_workflow_args_from_prompt)
4506 {
4507 *tool_name = "run_workspace_workflow".to_string();
4508 *args = prompt_args;
4509 return;
4510 }
4511 }
4512 if should_rewrite_shell_to_fix_plan(tool_name, args, latest_user_prompt) {
4513 *tool_name = "inspect_host".to_string();
4514 *args = serde_json::json!({
4515 "topic": "fix_plan"
4516 });
4517 }
4518 fill_missing_fix_plan_issue(tool_name, args, latest_user_prompt);
4519}
4520
4521fn canonical_tool_call_key(tool_name: &str, args: &Value) -> String {
4522 format!(
4523 "{}:{}",
4524 tool_name,
4525 serde_json::to_string(args).unwrap_or_default()
4526 )
4527}
4528
4529fn normalized_tool_call_for_execution(
4530 tool_name: &str,
4531 raw_arguments: &str,
4532 gemma4_model: bool,
4533 latest_user_prompt: Option<&str>,
4534) -> (String, Value) {
4535 let normalized_arguments = if gemma4_model {
4536 crate::agent::inference::normalize_tool_argument_string(tool_name, raw_arguments)
4537 } else {
4538 raw_arguments.to_string()
4539 };
4540 let mut normalized_name = tool_name.to_string();
4541 let mut args = serde_json::from_str::<Value>(&normalized_arguments)
4542 .unwrap_or(Value::Object(Default::default()));
4543 rewrite_host_tool_call(&mut normalized_name, &mut args, latest_user_prompt);
4544 (normalized_name, args)
4545}
4546
4547#[cfg(test)]
4548fn normalized_tool_call_key_for_dedupe(
4549 tool_name: &str,
4550 raw_arguments: &str,
4551 gemma4_model: bool,
4552 latest_user_prompt: Option<&str>,
4553) -> String {
4554 let (normalized_name, args) = normalized_tool_call_for_execution(
4555 tool_name,
4556 raw_arguments,
4557 gemma4_model,
4558 latest_user_prompt,
4559 );
4560 canonical_tool_call_key(&normalized_name, &args)
4561}
4562
4563impl ConversationManager {
4564 fn check_authorization(
4566 &self,
4567 name: &str,
4568 args: &serde_json::Value,
4569 config: &crate::agent::config::HematiteConfig,
4570 yolo_flag: bool,
4571 ) -> crate::agent::permission_enforcer::AuthorizationDecision {
4572 crate::agent::permission_enforcer::authorize_tool_call(name, args, config, yolo_flag)
4573 }
4574
4575 async fn process_tool_call(
4577 &self,
4578 mut call: ToolCallFn,
4579 config: crate::agent::config::HematiteConfig,
4580 yolo: bool,
4581 tx: mpsc::Sender<InferenceEvent>,
4582 real_id: String,
4583 ) -> ToolExecutionOutcome {
4584 let mut msg_results = Vec::new();
4585 let gemma4_model =
4586 crate::agent::inference::is_gemma4_model_name(&self.engine.current_model());
4587 let normalized_arguments = if gemma4_model {
4588 crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments)
4589 } else {
4590 call.arguments.clone()
4591 };
4592
4593 let mut args: Value = match serde_json::from_str(&normalized_arguments) {
4595 Ok(v) => v,
4596 Err(_) => {
4597 match self
4598 .repair_tool_args(&call.name, &normalized_arguments, &tx)
4599 .await
4600 {
4601 Ok(v) => v,
4602 Err(e) => {
4603 let _ = tx
4604 .send(InferenceEvent::Thought(format!(
4605 "JSON Repair failed: {}",
4606 e
4607 )))
4608 .await;
4609 Value::Object(Default::default())
4610 }
4611 }
4612 }
4613 };
4614 let last_user_prompt = self
4615 .history
4616 .iter()
4617 .rev()
4618 .find(|message| message.role == "user")
4619 .map(|message| message.content.as_str());
4620 rewrite_host_tool_call(&mut call.name, &mut args, last_user_prompt);
4621
4622 let display = format_tool_display(&call.name, &args);
4623 let precondition_result = self.validate_action_preconditions(&call.name, &args).await;
4624 let auth = self.check_authorization(&call.name, &args, &config, yolo);
4625
4626 let decision_result = match precondition_result {
4628 Err(e) => Err(e),
4629 Ok(_) => match auth {
4630 crate::agent::permission_enforcer::AuthorizationDecision::Allow { .. } => Ok(()),
4631 crate::agent::permission_enforcer::AuthorizationDecision::Ask {
4632 reason,
4633 source: _,
4634 } => {
4635 let (approve_tx, approve_rx) = tokio::sync::oneshot::channel::<bool>();
4636 let _ = tx
4637 .send(InferenceEvent::ApprovalRequired {
4638 id: real_id.clone(),
4639 name: call.name.clone(),
4640 display: format!("{}\nWhy: {}", display, reason),
4641 diff: None,
4642 responder: approve_tx,
4643 })
4644 .await;
4645
4646 match approve_rx.await {
4647 Ok(true) => Ok(()),
4648 _ => Err("Declined by user".into()),
4649 }
4650 }
4651 crate::agent::permission_enforcer::AuthorizationDecision::Deny {
4652 reason, ..
4653 } => Err(reason),
4654 },
4655 };
4656 let blocked_by_policy =
4657 matches!(&decision_result, Err(e) if e.starts_with("Action blocked:"));
4658
4659 let (output, is_error) = match decision_result {
4661 Err(e) if e.starts_with("[auto-redirected shell→inspect_host") => (e, false),
4662 Err(e) => (format!("Error: {}", e), true),
4663 Ok(_) => {
4664 let _ = tx
4665 .send(InferenceEvent::ToolCallStart {
4666 id: real_id.clone(),
4667 name: call.name.clone(),
4668 args: display.clone(),
4669 })
4670 .await;
4671
4672 let result = if call.name.starts_with("lsp_") {
4673 let lsp = self.lsp_manager.clone();
4674 let path = args
4675 .get("path")
4676 .and_then(|v| v.as_str())
4677 .unwrap_or("")
4678 .to_string();
4679 let line = args.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
4680 let character =
4681 args.get("character").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
4682
4683 match call.name.as_str() {
4684 "lsp_definitions" => {
4685 crate::tools::lsp_tools::lsp_definitions(lsp, path, line, character)
4686 .await
4687 }
4688 "lsp_references" => {
4689 crate::tools::lsp_tools::lsp_references(lsp, path, line, character)
4690 .await
4691 }
4692 "lsp_hover" => {
4693 crate::tools::lsp_tools::lsp_hover(lsp, path, line, character).await
4694 }
4695 "lsp_search_symbol" => {
4696 let query = args
4697 .get("query")
4698 .and_then(|v| v.as_str())
4699 .unwrap_or_default()
4700 .to_string();
4701 crate::tools::lsp_tools::lsp_search_symbol(lsp, query).await
4702 }
4703 "lsp_rename_symbol" => {
4704 let new_name = args
4705 .get("new_name")
4706 .and_then(|v| v.as_str())
4707 .unwrap_or_default()
4708 .to_string();
4709 crate::tools::lsp_tools::lsp_rename_symbol(
4710 lsp, path, line, character, new_name,
4711 )
4712 .await
4713 }
4714 "lsp_get_diagnostics" => {
4715 crate::tools::lsp_tools::lsp_get_diagnostics(lsp, path).await
4716 }
4717 _ => Err(format!("Unknown LSP tool: {}", call.name)),
4718 }
4719 } else if call.name == "auto_pin_context" {
4720 let pts = args.get("paths").and_then(|v| v.as_array());
4721 let reason = args
4722 .get("reason")
4723 .and_then(|v| v.as_str())
4724 .unwrap_or("uninformed scoping");
4725 if let Some(arr) = pts {
4726 let mut pinned = Vec::new();
4727 {
4728 let mut guard = self.pinned_files.lock().await;
4729 const MAX_PINNED_SIZE: u64 = 25 * 1024 * 1024; for v in arr.iter().take(3) {
4732 if let Some(p) = v.as_str() {
4733 if let Ok(meta) = std::fs::metadata(p) {
4734 if meta.len() > MAX_PINNED_SIZE {
4735 let _ = tx.send(InferenceEvent::Thought(format!("[GUARD] Skipping {} - size ({} bytes) exceeds VRAM safety limit (25MB).", p, meta.len()))).await;
4736 continue;
4737 }
4738 if let Ok(content) = std::fs::read_to_string(p) {
4739 guard.insert(p.to_string(), content);
4740 pinned.push(p.to_string());
4741 }
4742 }
4743 }
4744 }
4745 }
4746 let msg = format!(
4747 "Autonomous Scoping: Locked {} in high-fidelity memory. Reason: {}",
4748 pinned.join(", "),
4749 reason
4750 );
4751 let _ = tx
4752 .send(InferenceEvent::Thought(format!("[AUTO-PIN] {}", msg)))
4753 .await;
4754 Ok(msg)
4755 } else {
4756 Err("Missing 'paths' array for auto_pin_context.".to_string())
4757 }
4758 } else if call.name == "list_pinned" {
4759 let paths_msg = {
4760 let pinned = self.pinned_files.lock().await;
4761 if pinned.is_empty() {
4762 "No files are currently pinned.".to_string()
4763 } else {
4764 let paths: Vec<_> = pinned.keys().cloned().collect();
4765 format!(
4766 "Currently pinned files in active memory:\n- {}",
4767 paths.join("\n- ")
4768 )
4769 }
4770 };
4771 Ok(paths_msg)
4772 } else if call.name.starts_with("mcp__") {
4773 let mut mcp = self.mcp_manager.lock().await;
4774 match mcp.call_tool(&call.name, &args).await {
4775 Ok(res) => Ok(res),
4776 Err(e) => Err(e.to_string()),
4777 }
4778 } else if call.name == "swarm" {
4779 let tasks_val = args.get("tasks").cloned().unwrap_or(Value::Array(vec![]));
4781 let max_workers = args
4782 .get("max_workers")
4783 .and_then(|v| v.as_u64())
4784 .unwrap_or(3) as usize;
4785
4786 let mut task_objs = Vec::new();
4787 if let Value::Array(arr) = tasks_val {
4788 for v in arr {
4789 let id = v
4790 .get("id")
4791 .and_then(|x| x.as_str())
4792 .unwrap_or("?")
4793 .to_string();
4794 let target = v
4795 .get("target")
4796 .and_then(|x| x.as_str())
4797 .unwrap_or("?")
4798 .to_string();
4799 let instruction = v
4800 .get("instruction")
4801 .and_then(|x| x.as_str())
4802 .unwrap_or("?")
4803 .to_string();
4804 task_objs.push(crate::agent::parser::WorkerTask {
4805 id,
4806 target,
4807 instruction,
4808 });
4809 }
4810 }
4811
4812 if task_objs.is_empty() {
4813 Err("No tasks provided for swarm.".to_string())
4814 } else {
4815 let (swarm_tx_internal, mut swarm_rx_internal) =
4816 tokio::sync::mpsc::channel(32);
4817 let tx_forwarder = tx.clone();
4818
4819 tokio::spawn(async move {
4821 while let Some(msg) = swarm_rx_internal.recv().await {
4822 match msg {
4823 crate::agent::swarm::SwarmMessage::Progress(id, p) => {
4824 let _ = tx_forwarder
4825 .send(InferenceEvent::Thought(format!(
4826 "Swarm [{}]: {}% complete",
4827 id, p
4828 )))
4829 .await;
4830 }
4831 crate::agent::swarm::SwarmMessage::ReviewRequest {
4832 worker_id,
4833 file_path,
4834 before: _,
4835 after: _,
4836 tx,
4837 } => {
4838 let (approve_tx, approve_rx) =
4839 tokio::sync::oneshot::channel::<bool>();
4840 let display = format!(
4841 "Swarm worker [{}]: Integrated changes into {:?}",
4842 worker_id, file_path
4843 );
4844 let _ = tx_forwarder
4845 .send(InferenceEvent::ApprovalRequired {
4846 id: format!("swarm_{}", worker_id),
4847 name: "swarm_apply".to_string(),
4848 display,
4849 diff: None,
4850 responder: approve_tx,
4851 })
4852 .await;
4853 if let Ok(approved) = approve_rx.await {
4854 let response = if approved {
4855 crate::agent::swarm::ReviewResponse::Accept
4856 } else {
4857 crate::agent::swarm::ReviewResponse::Reject
4858 };
4859 let _ = tx.send(response);
4860 }
4861 }
4862 crate::agent::swarm::SwarmMessage::Done => {}
4863 }
4864 }
4865 });
4866
4867 let coordinator = self.swarm_coordinator.clone();
4868 match coordinator
4869 .dispatch_swarm(task_objs, swarm_tx_internal, max_workers)
4870 .await
4871 {
4872 Ok(_) => Ok(
4873 "Swarm execution completed. Check files for integration results."
4874 .to_string(),
4875 ),
4876 Err(e) => Err(format!("Swarm failure: {}", e)),
4877 }
4878 }
4879 } else if call.name == "vision_analyze" {
4880 crate::tools::vision::vision_analyze(&self.engine, &args).await
4881 } else if matches!(
4882 call.name.as_str(),
4883 "edit_file" | "patch_hunk" | "multi_search_replace"
4884 ) && !yolo
4885 {
4886 let diff_result = match call.name.as_str() {
4890 "edit_file" => crate::tools::file_ops::compute_edit_file_diff(&args),
4891 "patch_hunk" => crate::tools::file_ops::compute_patch_hunk_diff(&args),
4892 _ => crate::tools::file_ops::compute_msr_diff(&args),
4893 };
4894 match diff_result {
4895 Ok(diff_text) => {
4896 let path_label =
4897 args.get("path").and_then(|v| v.as_str()).unwrap_or("file");
4898 let (appr_tx, appr_rx) = tokio::sync::oneshot::channel::<bool>();
4899 let _ = tx
4900 .send(InferenceEvent::ApprovalRequired {
4901 id: real_id.clone(),
4902 name: call.name.clone(),
4903 display: format!("Edit preview: {}", path_label),
4904 diff: Some(diff_text),
4905 responder: appr_tx,
4906 })
4907 .await;
4908 match appr_rx.await {
4909 Ok(true) => dispatch_tool(&call.name, &args).await,
4910 _ => Err("Edit declined by user.".into()),
4911 }
4912 }
4913 Err(_) => dispatch_tool(&call.name, &args).await,
4916 }
4917 } else if call.name == "verify_build" {
4918 crate::tools::verify_build::execute_streaming(&args, tx.clone()).await
4921 } else if call.name == "shell" {
4922 crate::tools::shell::execute_streaming(&args, tx.clone()).await
4925 } else {
4926 dispatch_tool(&call.name, &args).await
4927 };
4928
4929 match result {
4930 Ok(o) => (o, false),
4931 Err(e) => (format!("Error: {}", e), true),
4932 }
4933 }
4934 };
4935
4936 {
4938 if let Ok(mut econ) = self.engine.economics.lock() {
4939 econ.record_tool(&call.name, !is_error);
4940 }
4941 }
4942
4943 if !is_error {
4944 if matches!(call.name.as_str(), "read_file" | "inspect_lines") {
4945 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
4946 if call.name == "inspect_lines" {
4947 self.record_line_inspection(path).await;
4948 } else {
4949 self.record_read_observation(path).await;
4950 }
4951 }
4952 }
4953
4954 if call.name == "verify_build" {
4955 let ok = output.contains("BUILD OK")
4956 || output.contains("BUILD SUCCESS")
4957 || output.contains("BUILD OKAY");
4958 self.record_verify_build_result(ok, &output).await;
4959 }
4960
4961 if matches!(
4962 call.name.as_str(),
4963 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
4964 ) || is_mcp_mutating_tool(&call.name)
4965 {
4966 self.record_successful_mutation(action_target_path(&call.name, &args).as_deref())
4967 .await;
4968 }
4969
4970 if let Some(receipt) = self.build_action_receipt(&call.name, &args, &output, is_error) {
4971 msg_results.push(receipt);
4972 }
4973 }
4974
4975 if !is_error && (call.name == "edit_file" || call.name == "write_file") {
4979 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
4980 let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
4981 let ext = std::path::Path::new(path)
4982 .extension()
4983 .and_then(|e| e.to_str())
4984 .unwrap_or("");
4985 const SKIP_EXTS: &[&str] = &[
4986 "md",
4987 "toml",
4988 "json",
4989 "txt",
4990 "yml",
4991 "yaml",
4992 "cfg",
4993 "csv",
4994 "lock",
4995 "gitignore",
4996 ];
4997 let line_count = content.lines().count();
4998 if !path.is_empty()
4999 && !content.is_empty()
5000 && !SKIP_EXTS.contains(&ext)
5001 && line_count >= 50
5002 {
5003 if let Some(critique) = self.run_critic_check(path, content, &tx).await {
5004 msg_results.push(ChatMessage::system(&format!(
5005 "[CRITIC REVIEW OF {}]\nIssues found:\n\n{}",
5006 path, critique
5007 )));
5008 }
5009 }
5010 }
5011
5012 ToolExecutionOutcome {
5013 call_id: real_id,
5014 tool_name: call.name,
5015 args,
5016 output,
5017 is_error,
5018 blocked_by_policy,
5019 msg_results,
5020 }
5021 }
5022}
5023
5024struct ToolExecutionOutcome {
5027 call_id: String,
5028 tool_name: String,
5029 args: Value,
5030 output: String,
5031 is_error: bool,
5032 blocked_by_policy: bool,
5033 msg_results: Vec<ChatMessage>,
5034}
5035
5036#[derive(Clone)]
5037struct CachedToolResult {
5038 tool_name: String,
5039}
5040
5041fn is_code_like_path(path: &str) -> bool {
5042 let ext = std::path::Path::new(path)
5043 .extension()
5044 .and_then(|e| e.to_str())
5045 .unwrap_or("")
5046 .to_ascii_lowercase();
5047 matches!(
5048 ext.as_str(),
5049 "rs" | "js"
5050 | "ts"
5051 | "tsx"
5052 | "jsx"
5053 | "py"
5054 | "go"
5055 | "java"
5056 | "c"
5057 | "cpp"
5058 | "cc"
5059 | "h"
5060 | "hpp"
5061 | "cs"
5062 | "swift"
5063 | "kt"
5064 | "kts"
5065 | "rb"
5066 | "php"
5067 )
5068}
5069
5070pub fn format_tool_display(name: &str, args: &Value) -> String {
5073 let get = |key: &str| {
5074 args.get(key)
5075 .and_then(|v| v.as_str())
5076 .unwrap_or("")
5077 .to_string()
5078 };
5079 match name {
5080 "shell" => format!("$ {}", get("command")),
5081
5082 "trace_runtime_flow" => format!("trace runtime {}", get("topic")),
5083 "describe_toolchain" => format!("describe toolchain {}", get("topic")),
5084 "inspect_host" => format!("inspect host {}", get("topic")),
5085 _ => format!("{} {:?}", name, args),
5086 }
5087}
5088
5089pub(crate) fn shell_looks_like_structured_host_inspection(command: &str) -> bool {
5092 let lower = command.to_ascii_lowercase();
5093 [
5094 "$env:path",
5095 "pathvariable",
5096 "pip --version",
5097 "pipx --version",
5098 "winget --version",
5099 "choco",
5100 "scoop",
5101 "get-childitem",
5102 "gci ",
5103 "where.exe",
5104 "where ",
5105 "cargo --version",
5106 "rustc --version",
5107 "git --version",
5108 "node --version",
5109 "npm --version",
5110 "pnpm --version",
5111 "python --version",
5112 "python3 --version",
5113 "deno --version",
5114 "go version",
5115 "dotnet --version",
5116 "uv --version",
5117 "netstat",
5118 "findstr",
5119 "get-nettcpconnection",
5120 "tcpconnection",
5121 "listening",
5122 "ss -",
5123 "ss ",
5124 "lsof",
5125 "tasklist",
5126 "ipconfig",
5127 "get-netipconfiguration",
5128 "get-netadapter",
5129 "route print",
5130 "ifconfig",
5131 "ip addr",
5132 "ip route",
5133 "resolv.conf",
5134 "get-service",
5135 "sc query",
5136 "systemctl",
5137 "service --status-all",
5138 "get-process",
5139 "working set",
5140 "ps -eo",
5141 "ps aux",
5142 "desktop",
5143 "downloads",
5144 "get-netfirewallprofile",
5145 "win32_powerplan",
5146 "win32_operatingsystem",
5147 "win32_processor",
5148 "wmic",
5149 "loadpercentage",
5150 "totalvisiblememory",
5151 "freephysicalmemory",
5152 "get-wmiobject",
5153 "get-ciminstance",
5154 "get-cpu",
5155 "processorname",
5156 "clockspeed",
5157 "top memory",
5158 "top cpu",
5159 "resource usage",
5160 "powercfg",
5161 "uptime",
5162 "lastbootuptime",
5163 "hklm:",
5165 "hkcu:",
5166 "hklm:\\",
5167 "hkcu:\\",
5168 "currentversion",
5169 "productname",
5170 "displayversion",
5171 "get-itemproperty",
5172 "get-itempropertyvalue",
5173 "get-windowsupdatelog",
5175 "windowsupdatelog",
5176 "microsoft.update.session",
5177 "createupdatesearcher",
5178 "wuauserv",
5179 "usoclient",
5180 "get-hotfix",
5181 "wu_",
5182 "get-mpcomputerstatus",
5184 "get-mppreference",
5185 "get-mpthreat",
5186 "start-mpscan",
5187 "win32_computersecurity",
5188 "softwarelicensingproduct",
5189 "enablelua",
5190 "get-netfirewallrule",
5191 "netfirewallprofile",
5192 "antivirus",
5193 "defenderstatus",
5194 "get-physicaldisk",
5196 "get-disk",
5197 "msstoragedriver_failurepredic",
5198 "win32_diskdrive",
5199 "smartstatus",
5200 "diskstatus",
5201 "get-counter",
5202 "intensity",
5203 "benchmark",
5204 "thrash",
5205 "get-item",
5206 "test-path",
5207 "gpresult",
5209 "applied gpo",
5210 "cert:\\",
5211 "cert:",
5212 "component based servicing",
5213 "componentstore",
5214 "get-computerinfo",
5215 "win32_computersystem",
5216 "win32_battery",
5218 "batterystaticdata",
5219 "batteryfullchargedcapacity",
5220 "batterystatus",
5221 "estimatedchargeremaining",
5222 "get-winevent",
5224 "eventid",
5225 "bugcheck",
5226 "kernelpower",
5227 "win32_ntlogevent",
5228 "filterhashtable",
5229 "get-scheduledtask",
5231 "get-scheduledtaskinfo",
5232 "schtasks",
5233 "taskscheduler",
5234 "get-ciminstance win32",
5236 "get-wmiobject win32",
5237 "arp -",
5239 "arp -a",
5240 "tracert ",
5241 "traceroute ",
5242 "tracepath ",
5243 "get-dnsclientcache",
5244 "ipconfig /displaydns",
5245 "get-netroute",
5246 "route print",
5247 "ip neigh",
5248 "netsh winhttp show proxy",
5249 "get-itemproperty.*proxy",
5250 "get-netadapter",
5251 "netsh wlan show",
5252 "test-netconnection",
5253 "resolve-dnsname",
5254 "get-netfirewallrule",
5255 "docker ps",
5257 "docker info",
5258 "docker images",
5259 "docker container",
5260 "docker compose ls",
5261 "wsl --list",
5262 "wsl -l",
5263 "wsl --status",
5264 "wsl --version",
5265 "ssh -v",
5266 "get-service sshd",
5267 "get-service -name sshd",
5268 "cat ~/.ssh",
5269 "ls ~/.ssh",
5270 "ls -la ~/.ssh",
5271 "get-childitem env:",
5273 "dir env:",
5274 "printenv",
5275 "[environment]::getenvironmentvariable",
5276 "get-content.*hosts",
5277 "cat /etc/hosts",
5278 "type c:\\windows\\system32\\drivers\\etc\\hosts",
5279 "git config --global --list",
5280 "git config --list",
5281 "git config --global",
5282 "get-service mysql",
5284 "get-service postgresql",
5285 "get-service mongodb",
5286 "get-service redis",
5287 "get-service mssql",
5288 "get-service mariadb",
5289 "systemctl status postgresql",
5290 "systemctl status mysql",
5291 "systemctl status mongod",
5292 "systemctl status redis",
5293 "winget list",
5295 "get-package",
5296 "get-itempropert.*uninstall",
5297 "dpkg --get-selections",
5298 "rpm -qa",
5299 "brew list",
5300 "get-localuser",
5302 "get-localgroupmember",
5303 "net user",
5304 "query user",
5305 "net localgroup administrators",
5306 "auditpol /get",
5308 "auditpol",
5309 "get-smbshare",
5311 "get-smbserverconfiguration",
5312 "net share",
5313 "net use",
5314 "get-dnsclientserveraddress",
5316 "get-dnsclientdohserveraddress",
5317 "get-dnsclientglobalsetting",
5318 ]
5319 .iter()
5320 .any(|needle| lower.contains(needle))
5321}
5322
5323fn cap_output(text: &str, max_bytes: usize) -> String {
5326 cap_output_for_tool(text, max_bytes, "output")
5327}
5328
5329fn cap_output_for_tool(text: &str, max_bytes: usize, tool_name: &str) -> String {
5334 if text.len() <= max_bytes {
5335 return text.to_string();
5336 }
5337
5338 let scratch_path = write_output_to_scratch(text, tool_name);
5340
5341 let mut split_at = max_bytes;
5342 while !text.is_char_boundary(split_at) && split_at > 0 {
5343 split_at -= 1;
5344 }
5345
5346 let tail = match &scratch_path {
5347 Some(p) => format!(
5348 "\n... [output truncated — full output ({} bytes, {} lines) saved to '{}' — use read_file to access the rest]",
5349 text.len(),
5350 text.lines().count(),
5351 p
5352 ),
5353 None => format!("\n... [output capped at {}B]", max_bytes),
5354 };
5355
5356 format!("{}{}", &text[..split_at], tail)
5357}
5358
5359fn write_output_to_scratch(text: &str, tool_name: &str) -> Option<String> {
5362 let root = crate::tools::file_ops::workspace_root();
5363 let scratch_dir = root.join(".hematite").join("scratch");
5364 if std::fs::create_dir_all(&scratch_dir).is_err() {
5365 return None;
5366 }
5367 let ts = std::time::SystemTime::now()
5368 .duration_since(std::time::UNIX_EPOCH)
5369 .map(|d| d.as_secs())
5370 .unwrap_or(0);
5371 let safe_name: String = tool_name
5373 .chars()
5374 .map(|c| {
5375 if c.is_alphanumeric() || c == '_' {
5376 c
5377 } else {
5378 '_'
5379 }
5380 })
5381 .collect();
5382 let filename = format!("{}_{}.txt", safe_name, ts);
5383 let abs_path = scratch_dir.join(&filename);
5384 if std::fs::write(&abs_path, text).is_err() {
5385 return None;
5386 }
5387 Some(format!(".hematite/scratch/{}", filename))
5388}
5389
5390#[derive(Default)]
5391struct PromptBudgetStats {
5392 summarized_tool_results: usize,
5393 collapsed_tool_results: usize,
5394 trimmed_chat_messages: usize,
5395 dropped_messages: usize,
5396}
5397
5398fn estimate_prompt_tokens(messages: &[ChatMessage]) -> usize {
5399 crate::agent::inference::estimate_message_batch_tokens(messages)
5400}
5401
5402fn summarize_prompt_blob(text: &str, max_chars: usize) -> String {
5403 let budget = compaction::SummaryCompressionBudget {
5404 max_chars,
5405 max_lines: 3,
5406 max_line_chars: max_chars.clamp(80, 240),
5407 };
5408 let compressed = compaction::compress_summary(text, budget).summary;
5409 if compressed.is_empty() {
5410 String::new()
5411 } else {
5412 compressed
5413 }
5414}
5415
5416fn summarize_tool_message_for_budget(message: &ChatMessage) -> String {
5417 let tool_name = message.name.as_deref().unwrap_or("tool");
5418 let body = summarize_prompt_blob(message.content.as_str(), 320);
5419 format!(
5420 "[Prompt-budget summary of prior `{}` result]\n{}",
5421 tool_name, body
5422 )
5423}
5424
5425fn summarize_chat_message_for_budget(message: &ChatMessage) -> String {
5426 let role = message.role.as_str();
5427 let body = summarize_prompt_blob(message.content.as_str(), 240);
5428 format!(
5429 "[Prompt-budget summary of earlier {} message]\n{}",
5430 role, body
5431 )
5432}
5433
5434fn normalize_prompt_start(messages: &mut Vec<ChatMessage>) {
5435 if messages.len() > 1 && messages[1].role != "user" {
5436 messages.insert(1, ChatMessage::user("Continuing previous context..."));
5437 }
5438}
5439
5440fn enforce_prompt_budget(
5441 prompt_msgs: &mut Vec<ChatMessage>,
5442 context_length: usize,
5443) -> Option<String> {
5444 let target_tokens = ((context_length as f64) * 0.68) as usize;
5445 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5446 return None;
5447 }
5448
5449 let mut stats = PromptBudgetStats::default();
5450
5451 let mut tool_indices: Vec<usize> = prompt_msgs
5453 .iter()
5454 .enumerate()
5455 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
5456 .collect();
5457 for idx in tool_indices.iter().rev().copied() {
5458 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5459 break;
5460 }
5461 let original = prompt_msgs[idx].content.as_str().to_string();
5462 if original.len() > 1200 {
5463 prompt_msgs[idx].content =
5464 MessageContent::Text(summarize_tool_message_for_budget(&prompt_msgs[idx]));
5465 stats.summarized_tool_results += 1;
5466 }
5467 }
5468
5469 tool_indices = prompt_msgs
5471 .iter()
5472 .enumerate()
5473 .filter_map(|(idx, msg)| (msg.role == "tool").then_some(idx))
5474 .collect();
5475 if tool_indices.len() > 2 {
5476 for idx in tool_indices
5477 .iter()
5478 .take(tool_indices.len().saturating_sub(2))
5479 .copied()
5480 {
5481 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5482 break;
5483 }
5484 prompt_msgs[idx].content = MessageContent::Text(
5485 "[Earlier tool output omitted to stay within the prompt budget.]".to_string(),
5486 );
5487 stats.collapsed_tool_results += 1;
5488 }
5489 }
5490
5491 let last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
5493 for idx in 1..prompt_msgs.len() {
5494 if estimate_prompt_tokens(prompt_msgs) <= target_tokens {
5495 break;
5496 }
5497 if Some(idx) == last_user_idx {
5498 continue;
5499 }
5500 let role = prompt_msgs[idx].role.as_str();
5501 if matches!(role, "user" | "assistant") && prompt_msgs[idx].content.as_str().len() > 900 {
5502 prompt_msgs[idx].content =
5503 MessageContent::Text(summarize_chat_message_for_budget(&prompt_msgs[idx]));
5504 stats.trimmed_chat_messages += 1;
5505 }
5506 }
5507
5508 let preserve_last_user_idx = prompt_msgs.iter().rposition(|m| m.role == "user");
5510 let mut idx = 1usize;
5511 while estimate_prompt_tokens(prompt_msgs) > target_tokens && prompt_msgs.len() > 2 {
5512 if Some(idx) == preserve_last_user_idx {
5513 idx += 1;
5514 if idx >= prompt_msgs.len() {
5515 break;
5516 }
5517 continue;
5518 }
5519 if idx >= prompt_msgs.len() {
5520 break;
5521 }
5522 prompt_msgs.remove(idx);
5523 stats.dropped_messages += 1;
5524 }
5525
5526 normalize_prompt_start(prompt_msgs);
5527
5528 let new_tokens = estimate_prompt_tokens(prompt_msgs);
5529 if stats.summarized_tool_results == 0
5530 && stats.collapsed_tool_results == 0
5531 && stats.trimmed_chat_messages == 0
5532 && stats.dropped_messages == 0
5533 {
5534 return None;
5535 }
5536
5537 Some(format!(
5538 "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).",
5539 new_tokens,
5540 target_tokens,
5541 stats.summarized_tool_results,
5542 stats.collapsed_tool_results,
5543 stats.trimmed_chat_messages,
5544 stats.dropped_messages
5545 ))
5546}
5547
5548fn is_quick_tool_request(input: &str) -> bool {
5553 let lower = input.to_lowercase();
5554 if lower.contains("run_code") || lower.contains("run code") {
5556 return true;
5557 }
5558 let is_short = input.len() < 120;
5560 let compute_keywords = [
5561 "calculate",
5562 "compute",
5563 "execute",
5564 "run this",
5565 "test this",
5566 "what is ",
5567 "how much",
5568 "how many",
5569 "convert ",
5570 "print ",
5571 ];
5572 if is_short && compute_keywords.iter().any(|k| lower.contains(k)) {
5573 return true;
5574 }
5575 false
5576}
5577
5578fn chunk_text(text: &str, words_per_chunk: usize) -> Vec<String> {
5579 let mut chunks = Vec::new();
5580 let mut current = String::new();
5581 let mut count = 0;
5582
5583 for ch in text.chars() {
5584 current.push(ch);
5585 if ch == ' ' || ch == '\n' {
5586 count += 1;
5587 if count >= words_per_chunk {
5588 chunks.push(current.clone());
5589 current.clear();
5590 count = 0;
5591 }
5592 }
5593 }
5594 if !current.is_empty() {
5595 chunks.push(current);
5596 }
5597 chunks
5598}
5599
5600fn repeated_read_target(call: &crate::agent::inference::ToolCallFn) -> Option<String> {
5601 if call.name != "read_file" {
5602 return None;
5603 }
5604 let normalized_arguments =
5605 crate::agent::inference::normalize_tool_argument_string(&call.name, &call.arguments);
5606 let args: Value = serde_json::from_str(&normalized_arguments).ok()?;
5607 let path = args.get("path").and_then(|v| v.as_str())?;
5608 Some(normalize_workspace_path(path))
5609}
5610
5611fn order_batch_reads_first(
5612 calls: Vec<crate::agent::inference::ToolCallResponse>,
5613) -> (
5614 Vec<crate::agent::inference::ToolCallResponse>,
5615 Option<String>,
5616) {
5617 let has_reads = calls.iter().any(|c| {
5618 matches!(
5619 c.function.name.as_str(),
5620 "read_file" | "inspect_lines" | "grep_files" | "list_files"
5621 )
5622 });
5623 let has_edits = calls.iter().any(|c| {
5624 matches!(
5625 c.function.name.as_str(),
5626 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
5627 )
5628 });
5629 if has_reads && has_edits {
5630 let reads: Vec<_> = calls
5631 .into_iter()
5632 .filter(|c| {
5633 !matches!(
5634 c.function.name.as_str(),
5635 "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace"
5636 )
5637 })
5638 .collect();
5639 let note = Some("Batch ordering: deferring edits until reads complete.".to_string());
5640 (reads, note)
5641 } else {
5642 (calls, None)
5643 }
5644}
5645
5646fn grep_output_is_high_fanout(output: &str) -> bool {
5647 let Some(summary) = output.lines().next() else {
5648 return false;
5649 };
5650 let hunk_count = summary
5651 .split(", ")
5652 .find_map(|part| {
5653 part.strip_suffix(" hunk(s)")
5654 .and_then(|value| value.parse::<usize>().ok())
5655 })
5656 .unwrap_or(0);
5657 let match_count = summary
5658 .split(' ')
5659 .next()
5660 .and_then(|value| value.parse::<usize>().ok())
5661 .unwrap_or(0);
5662 hunk_count >= 8 || match_count >= 12
5663}
5664
5665fn build_system_with_corrections(
5666 base: &str,
5667 hints: &[String],
5668 gpu: &Arc<GpuState>,
5669 git: &Arc<crate::agent::git_monitor::GitState>,
5670 config: &crate::agent::config::HematiteConfig,
5671) -> String {
5672 let mut system_msg = base.to_string();
5673
5674 system_msg.push_str("\n\n# Permission Mode\n");
5676 let mode_label = match config.mode {
5677 crate::agent::config::PermissionMode::ReadOnly => "READ-ONLY",
5678 crate::agent::config::PermissionMode::Developer => "DEVELOPER",
5679 crate::agent::config::PermissionMode::SystemAdmin => "SYSTEM-ADMIN (UNRESTRICTED)",
5680 };
5681 system_msg.push_str(&format!("CURRENT MODE: {}\n", mode_label));
5682
5683 if config.mode == crate::agent::config::PermissionMode::ReadOnly {
5684 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");
5685 } else {
5686 system_msg.push_str("PERMISSION: You have authority to modify code and execute tests with user oversight.\n");
5687 }
5688
5689 let (used, total) = gpu.read();
5691 if total > 0 {
5692 system_msg.push_str("\n\n# Terminal Hardware Context\n");
5693 system_msg.push_str(&format!(
5694 "HOST GPU: {} | VRAM: {:.1}GB / {:.1}GB ({:.0}% used)\n",
5695 gpu.gpu_name(),
5696 used as f64 / 1024.0,
5697 total as f64 / 1024.0,
5698 gpu.ratio() * 100.0
5699 ));
5700 system_msg.push_str("Use this awareness to manage your context window responsibly.\n");
5701 }
5702
5703 system_msg.push_str("\n\n# Git Repository Context\n");
5705 let git_status_label = git.label();
5706 let git_url = git.url();
5707 system_msg.push_str(&format!(
5708 "REMOTE STATUS: {} | URL: {}\n",
5709 git_status_label, git_url
5710 ));
5711
5712 let root = crate::tools::file_ops::workspace_root();
5714 if let Some(status_snapshot) = crate::agent::git_context::read_git_status(&root) {
5715 system_msg.push_str("\nGit status snapshot:\n");
5716 system_msg.push_str(&status_snapshot);
5717 system_msg.push_str("\n");
5718 }
5719
5720 if let Some(diff_snapshot) = crate::agent::git_context::read_git_diff(&root, 2000) {
5721 system_msg.push_str("\nGit diff snapshot:\n");
5722 system_msg.push_str(&diff_snapshot);
5723 system_msg.push_str("\n");
5724 }
5725
5726 if git_status_label == "NONE" {
5727 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");
5728 } else if git_status_label == "BEHIND" {
5729 system_msg.push_str("\nSYNC: Local is behind remote. Suggest a pull if appropriate.\n");
5730 }
5731
5732 if hints.is_empty() {
5737 return system_msg;
5738 }
5739 system_msg.push_str("\n\n# Formatting Corrections\n");
5740 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");
5741 for hint in hints {
5742 system_msg.push_str(&format!("- {}\n", hint));
5743 }
5744 system_msg
5745}
5746
5747fn route_model<'a>(
5748 user_input: &str,
5749 fast_model: Option<&'a str>,
5750 think_model: Option<&'a str>,
5751) -> Option<&'a str> {
5752 let text = user_input.to_lowercase();
5753 let is_think = text.contains("refactor")
5754 || text.contains("rewrite")
5755 || text.contains("implement")
5756 || text.contains("create")
5757 || text.contains("fix")
5758 || text.contains("debug");
5759 let is_fast = text.contains("what")
5760 || text.contains("show")
5761 || text.contains("find")
5762 || text.contains("list")
5763 || text.contains("status");
5764
5765 if is_think && think_model.is_some() {
5766 return think_model;
5767 } else if is_fast && fast_model.is_some() {
5768 return fast_model;
5769 }
5770 None
5771}
5772
5773fn is_parallel_safe(name: &str) -> bool {
5774 let metadata = crate::agent::inference::tool_metadata_for_name(name);
5775 !metadata.mutates_workspace && !metadata.external_surface
5776}
5777
5778fn should_use_vein_in_chat(query: &str, docs_only_mode: bool) -> bool {
5779 if docs_only_mode {
5780 return true;
5781 }
5782
5783 let lower = query.to_ascii_lowercase();
5784 [
5785 "what did we decide",
5786 "why did we decide",
5787 "what did we say",
5788 "what did we do",
5789 "earlier today",
5790 "yesterday",
5791 "last week",
5792 "last month",
5793 "earlier",
5794 "remember",
5795 "session",
5796 "import",
5797 ]
5798 .iter()
5799 .any(|needle| lower.contains(needle))
5800 || lower
5801 .split(|ch: char| !(ch.is_ascii_digit() || ch == '-'))
5802 .any(|token| token.len() == 10 && token.chars().nth(4) == Some('-'))
5803}
5804
5805#[cfg(test)]
5806mod tests {
5807 use super::*;
5808
5809 #[test]
5810 fn classifies_lm_studio_context_budget_mismatch_as_context_window() {
5811 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."}"#;
5812 let class = classify_runtime_failure(detail);
5813 assert_eq!(class, RuntimeFailureClass::ContextWindow);
5814 assert_eq!(class.tag(), "context_window");
5815 assert!(format_runtime_failure(class, detail).contains("[failure:context_window]"));
5816 }
5817
5818 #[test]
5819 fn runtime_failure_maps_to_provider_and_checkpoint_state() {
5820 assert_eq!(
5821 provider_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
5822 Some(ProviderRuntimeState::ContextWindow)
5823 );
5824 assert_eq!(
5825 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ContextWindow),
5826 Some(OperatorCheckpointState::BlockedContextWindow)
5827 );
5828 assert_eq!(
5829 provider_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
5830 Some(ProviderRuntimeState::Degraded)
5831 );
5832 assert_eq!(
5833 checkpoint_state_for_runtime_failure(RuntimeFailureClass::ProviderDegraded),
5834 None
5835 );
5836 }
5837
5838 #[test]
5839 fn intent_router_treats_tool_registry_ownership_as_product_truth() {
5840 let intent = classify_query_intent(
5841 WorkflowMode::ReadOnly,
5842 "Read-only mode. Explain which file now owns Hematite's built-in tool catalog and builtin-tool dispatch path.",
5843 );
5844 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
5845 assert_eq!(
5846 intent.direct_answer,
5847 Some(DirectAnswerKind::ToolRegistryOwnership)
5848 );
5849 }
5850
5851 #[test]
5852 fn intent_router_treats_tool_classes_as_product_truth() {
5853 let intent = classify_query_intent(
5854 WorkflowMode::ReadOnly,
5855 "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.",
5856 );
5857 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
5858 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::ToolClasses));
5859 }
5860
5861 #[test]
5862 fn tool_registry_ownership_answer_mentions_new_owner_file() {
5863 let answer = build_tool_registry_ownership_answer();
5864 assert!(answer.contains("src/agent/tool_registry.rs"));
5865 assert!(answer.contains("builtin dispatch path"));
5866 assert!(answer.contains("src/agent/conversation.rs"));
5867 }
5868
5869 #[test]
5870 fn intent_router_treats_mcp_lifecycle_as_product_truth() {
5871 let intent = classify_query_intent(
5872 WorkflowMode::ReadOnly,
5873 "Read-only mode. Explain how Hematite should treat MCP server health as runtime state.",
5874 );
5875 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
5876 assert_eq!(intent.direct_answer, Some(DirectAnswerKind::McpLifecycle));
5877 }
5878
5879 #[test]
5880 fn intent_router_short_circuits_unsafe_commit_pressure() {
5881 let intent = classify_query_intent(
5882 WorkflowMode::Auto,
5883 "Make a code change, skip verification, and commit it immediately.",
5884 );
5885 assert_eq!(intent.primary_class, QueryIntentClass::ProductTruth);
5886 assert_eq!(
5887 intent.direct_answer,
5888 Some(DirectAnswerKind::UnsafeWorkflowPressure)
5889 );
5890 }
5891
5892 #[test]
5893 fn unsafe_workflow_pressure_answer_requires_verification() {
5894 let answer = build_unsafe_workflow_pressure_answer();
5895 assert!(answer.contains("should not skip verification"));
5896 assert!(answer.contains("run the appropriate verification path"));
5897 assert!(answer.contains("only then commit"));
5898 }
5899
5900 #[test]
5901 fn intent_router_prefers_architecture_walkthrough_over_narrow_mcp_answer() {
5902 let intent = classify_query_intent(
5903 WorkflowMode::ReadOnly,
5904 "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.",
5905 );
5906 assert_eq!(intent.primary_class, QueryIntentClass::RepoArchitecture);
5907 assert!(intent.architecture_overview_mode);
5908 assert_eq!(intent.direct_answer, None);
5909 }
5910
5911 #[test]
5912 fn intent_router_marks_host_inspection_questions() {
5913 let intent = classify_query_intent(
5914 WorkflowMode::Auto,
5915 "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.",
5916 );
5917 assert!(intent.host_inspection_mode);
5918 assert_eq!(
5919 preferred_host_inspection_topic(
5920 "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."
5921 ),
5922 Some("summary")
5923 );
5924 }
5925
5926 #[test]
5927 fn chat_mode_uses_vein_for_historical_or_docs_only_queries() {
5928 assert!(should_use_vein_in_chat(
5929 "What did we decide on 2026-04-09 about docs-only mode?",
5930 false
5931 ));
5932 assert!(should_use_vein_in_chat("Summarize these local notes", true));
5933 assert!(!should_use_vein_in_chat("Tell me a joke", false));
5934 }
5935
5936 #[test]
5937 fn shell_host_inspection_guard_matches_path_and_version_commands() {
5938 assert!(shell_looks_like_structured_host_inspection(
5939 "$env:PATH -split ';'"
5940 ));
5941 assert!(shell_looks_like_structured_host_inspection(
5942 "cargo --version"
5943 ));
5944 assert!(shell_looks_like_structured_host_inspection(
5945 "Get-NetTCPConnection -LocalPort 3000"
5946 ));
5947 assert!(shell_looks_like_structured_host_inspection(
5948 "netstat -ano | findstr :3000"
5949 ));
5950 assert!(shell_looks_like_structured_host_inspection(
5951 "Get-Process | Sort-Object WS -Descending"
5952 ));
5953 assert!(shell_looks_like_structured_host_inspection("ipconfig /all"));
5954 assert!(shell_looks_like_structured_host_inspection("Get-Service"));
5955 assert!(shell_looks_like_structured_host_inspection(
5956 "winget --version"
5957 ));
5958 }
5959
5960 #[test]
5961 fn intent_router_picks_ports_for_listening_port_questions() {
5962 assert_eq!(
5963 preferred_host_inspection_topic(
5964 "Show me what is listening on port 3000 and whether anything unexpected is exposed."
5965 ),
5966 Some("ports")
5967 );
5968 }
5969
5970 #[test]
5971 fn intent_router_picks_processes_for_host_process_questions() {
5972 assert_eq!(
5973 preferred_host_inspection_topic(
5974 "Show me what processes are using the most RAM right now."
5975 ),
5976 Some("processes")
5977 );
5978 }
5979
5980 #[test]
5981 fn intent_router_picks_network_for_adapter_questions() {
5982 assert_eq!(
5983 preferred_host_inspection_topic(
5984 "Show me my active network adapters, IP addresses, gateways, and DNS servers."
5985 ),
5986 Some("network")
5987 );
5988 }
5989
5990 #[test]
5991 fn intent_router_picks_services_for_service_questions() {
5992 assert_eq!(
5993 preferred_host_inspection_topic(
5994 "Show me the running services and startup types that matter for a normal dev machine."
5995 ),
5996 Some("services")
5997 );
5998 }
5999
6000 #[test]
6001 fn intent_router_picks_env_doctor_for_package_manager_questions() {
6002 assert_eq!(
6003 preferred_host_inspection_topic(
6004 "Run an environment doctor on this machine and tell me whether my PATH and package managers look sane."
6005 ),
6006 Some("env_doctor")
6007 );
6008 }
6009
6010 #[test]
6011 fn intent_router_picks_fix_plan_for_host_remediation_questions() {
6012 assert_eq!(
6013 preferred_host_inspection_topic("How do I fix cargo not found on this machine?"),
6014 Some("fix_plan")
6015 );
6016 assert_eq!(
6017 preferred_host_inspection_topic(
6018 "How do I fix Hematite when LM Studio is not reachable on localhost:1234?"
6019 ),
6020 Some("fix_plan")
6021 );
6022 }
6023
6024 #[test]
6025 fn fill_missing_fix_plan_issue_backfills_last_user_prompt() {
6026 let mut args = serde_json::json!({
6027 "topic": "fix_plan"
6028 });
6029
6030 fill_missing_fix_plan_issue(
6031 "inspect_host",
6032 &mut args,
6033 Some("/think\nHow do I fix cargo not found on this machine?"),
6034 );
6035
6036 assert_eq!(
6037 args.get("issue").and_then(|value| value.as_str()),
6038 Some("How do I fix cargo not found on this machine?")
6039 );
6040 }
6041
6042 #[test]
6043 fn shell_fix_question_rewrites_to_fix_plan() {
6044 let args = serde_json::json!({
6045 "command": "where cargo"
6046 });
6047
6048 assert!(should_rewrite_shell_to_fix_plan(
6049 "shell",
6050 &args,
6051 Some("How do I fix cargo not found on this machine?")
6052 ));
6053 }
6054
6055 #[test]
6056 fn fix_plan_dedupe_key_matches_rewritten_shell_probe() {
6057 let latest_user_prompt = Some("How do I fix cargo not found on this machine?");
6058 let shell_key = normalized_tool_call_key_for_dedupe(
6059 "shell",
6060 r#"{"command":"where cargo"}"#,
6061 false,
6062 latest_user_prompt,
6063 );
6064 let fix_plan_key = normalized_tool_call_key_for_dedupe(
6065 "inspect_host",
6066 r#"{"topic":"fix_plan"}"#,
6067 false,
6068 latest_user_prompt,
6069 );
6070
6071 assert_eq!(shell_key, fix_plan_key);
6072 }
6073
6074 #[test]
6075 fn shell_cleanup_script_rewrites_to_maintainer_workflow() {
6076 let (tool_name, args) = normalized_tool_call_for_execution(
6077 "shell",
6078 r#"{"command":"pwsh ./clean.ps1 -Deep -PruneDist"}"#,
6079 false,
6080 Some("Run my cleanup scripts."),
6081 );
6082
6083 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6084 assert_eq!(
6085 args.get("workflow").and_then(|value| value.as_str()),
6086 Some("clean")
6087 );
6088 assert_eq!(
6089 args.get("deep").and_then(|value| value.as_bool()),
6090 Some(true)
6091 );
6092 assert_eq!(
6093 args.get("prune_dist").and_then(|value| value.as_bool()),
6094 Some(true)
6095 );
6096 }
6097
6098 #[test]
6099 fn shell_release_script_rewrites_to_maintainer_workflow() {
6100 let (tool_name, args) = normalized_tool_call_for_execution(
6101 "shell",
6102 r#"{"command":"pwsh ./release.ps1 -Version 0.4.5 -Push -AddToPath"}"#,
6103 false,
6104 Some("Run the release flow."),
6105 );
6106
6107 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6108 assert_eq!(
6109 args.get("workflow").and_then(|value| value.as_str()),
6110 Some("release")
6111 );
6112 assert_eq!(
6113 args.get("version").and_then(|value| value.as_str()),
6114 Some("0.4.5")
6115 );
6116 assert_eq!(
6117 args.get("push").and_then(|value| value.as_bool()),
6118 Some(true)
6119 );
6120 }
6121
6122 #[test]
6123 fn explicit_cleanup_prompt_rewrites_shell_to_maintainer_workflow() {
6124 let (tool_name, args) = normalized_tool_call_for_execution(
6125 "shell",
6126 r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
6127 false,
6128 Some("Run the deep cleanup and prune old dist artifacts."),
6129 );
6130
6131 assert_eq!(tool_name, "run_hematite_maintainer_workflow");
6132 assert_eq!(
6133 args.get("workflow").and_then(|value| value.as_str()),
6134 Some("clean")
6135 );
6136 assert_eq!(
6137 args.get("deep").and_then(|value| value.as_bool()),
6138 Some(true)
6139 );
6140 assert_eq!(
6141 args.get("prune_dist").and_then(|value| value.as_bool()),
6142 Some(true)
6143 );
6144 }
6145
6146 #[test]
6147 fn shell_cargo_test_rewrites_to_workspace_workflow() {
6148 let (tool_name, args) = normalized_tool_call_for_execution(
6149 "shell",
6150 r#"{"command":"cargo test"}"#,
6151 false,
6152 Some("Run cargo test in this project."),
6153 );
6154
6155 assert_eq!(tool_name, "run_workspace_workflow");
6156 assert_eq!(
6157 args.get("workflow").and_then(|value| value.as_str()),
6158 Some("command")
6159 );
6160 assert_eq!(
6161 args.get("command").and_then(|value| value.as_str()),
6162 Some("cargo test")
6163 );
6164 }
6165
6166 #[test]
6167 fn current_plan_execution_request_accepts_saved_plan_command() {
6168 assert!(is_current_plan_execution_request("/implement-plan"));
6169 assert!(is_current_plan_execution_request(
6170 "Implement the current plan."
6171 ));
6172 }
6173
6174 #[test]
6175 fn architect_operator_note_points_to_execute_path() {
6176 let plan = crate::tools::plan::PlanHandoff {
6177 goal: "Tighten startup workflow guidance".into(),
6178 target_files: vec!["src/runtime.rs".into()],
6179 ordered_steps: vec!["Update the startup banner".into()],
6180 verification: "cargo check --tests".into(),
6181 risks: vec![],
6182 open_questions: vec![],
6183 };
6184 let note = architect_handoff_operator_note(&plan);
6185 assert!(note.contains("`.hematite/PLAN.md`"));
6186 assert!(note.contains("/implement-plan"));
6187 assert!(note.contains("/code implement the current plan"));
6188 }
6189
6190 #[test]
6191 fn natural_language_test_prompt_rewrites_to_workspace_workflow() {
6192 let (tool_name, args) = normalized_tool_call_for_execution(
6193 "shell",
6194 r#"{"command":"powershell -Command \"Get-ChildItem .\""}"#,
6195 false,
6196 Some("Run the tests in this project."),
6197 );
6198
6199 assert_eq!(tool_name, "run_workspace_workflow");
6200 assert_eq!(
6201 args.get("workflow").and_then(|value| value.as_str()),
6202 Some("test")
6203 );
6204 }
6205
6206 #[test]
6207 fn failing_path_parser_extracts_cargo_error_locations() {
6208 let output = r#"
6209BUILD FAILURE: The build is currently broken. FIX THESE ERRORS IMMEDIATELY:
6210
6211error[E0412]: cannot find type `Foo` in this scope
6212 --> src/agent/conversation.rs:42:12
6213 |
621442 | field: Foo,
6215 | ^^^ not found
6216
6217error[E0308]: mismatched types
6218 --> src/tools/file_ops.rs:100:5
6219 |
6220 = note: expected `String`, found `&str`
6221"#;
6222 let paths = parse_failing_paths_from_build_output(output);
6223 assert!(
6224 paths.iter().any(|p| p.contains("conversation.rs")),
6225 "should capture conversation.rs"
6226 );
6227 assert!(
6228 paths.iter().any(|p| p.contains("file_ops.rs")),
6229 "should capture file_ops.rs"
6230 );
6231 assert_eq!(paths.len(), 2, "no duplicates");
6232 }
6233
6234 #[test]
6235 fn failing_path_parser_ignores_macro_expansions() {
6236 let output = r#"
6237 --> <macro-expansion>:1:2
6238 --> src/real/file.rs:10:5
6239"#;
6240 let paths = parse_failing_paths_from_build_output(output);
6241 assert_eq!(paths.len(), 1);
6242 assert!(paths[0].contains("file.rs"));
6243 }
6244
6245 #[test]
6246 fn intent_router_picks_updates_for_update_questions() {
6247 assert_eq!(
6248 preferred_host_inspection_topic("is my PC up to date?"),
6249 Some("updates")
6250 );
6251 assert_eq!(
6252 preferred_host_inspection_topic("are there any pending Windows updates?"),
6253 Some("updates")
6254 );
6255 assert_eq!(
6256 preferred_host_inspection_topic("check for updates on my computer"),
6257 Some("updates")
6258 );
6259 }
6260
6261 #[test]
6262 fn intent_router_picks_security_for_antivirus_questions() {
6263 assert_eq!(
6264 preferred_host_inspection_topic("is my antivirus on?"),
6265 Some("security")
6266 );
6267 assert_eq!(
6268 preferred_host_inspection_topic("is Windows Defender running?"),
6269 Some("security")
6270 );
6271 assert_eq!(
6272 preferred_host_inspection_topic("is my PC protected?"),
6273 Some("security")
6274 );
6275 }
6276
6277 #[test]
6278 fn intent_router_picks_pending_reboot_for_restart_questions() {
6279 assert_eq!(
6280 preferred_host_inspection_topic("do I need to restart my PC?"),
6281 Some("pending_reboot")
6282 );
6283 assert_eq!(
6284 preferred_host_inspection_topic("is a reboot required?"),
6285 Some("pending_reboot")
6286 );
6287 assert_eq!(
6288 preferred_host_inspection_topic("is there a pending restart waiting?"),
6289 Some("pending_reboot")
6290 );
6291 }
6292
6293 #[test]
6294 fn intent_router_picks_disk_health_for_drive_health_questions() {
6295 assert_eq!(
6296 preferred_host_inspection_topic("is my hard drive dying?"),
6297 Some("disk_health")
6298 );
6299 assert_eq!(
6300 preferred_host_inspection_topic("check the disk health and SMART status"),
6301 Some("disk_health")
6302 );
6303 assert_eq!(
6304 preferred_host_inspection_topic("is my SSD healthy?"),
6305 Some("disk_health")
6306 );
6307 }
6308
6309 #[test]
6310 fn intent_router_picks_battery_for_battery_questions() {
6311 assert_eq!(
6312 preferred_host_inspection_topic("check my battery"),
6313 Some("battery")
6314 );
6315 assert_eq!(
6316 preferred_host_inspection_topic("how is my battery life?"),
6317 Some("battery")
6318 );
6319 assert_eq!(
6320 preferred_host_inspection_topic("what is my battery wear level?"),
6321 Some("battery")
6322 );
6323 }
6324
6325 #[test]
6326 fn intent_router_picks_recent_crashes_for_bsod_questions() {
6327 assert_eq!(
6328 preferred_host_inspection_topic("why did my PC restart by itself?"),
6329 Some("recent_crashes")
6330 );
6331 assert_eq!(
6332 preferred_host_inspection_topic("did my computer BSOD recently?"),
6333 Some("recent_crashes")
6334 );
6335 assert_eq!(
6336 preferred_host_inspection_topic("show me any recent app crashes"),
6337 Some("recent_crashes")
6338 );
6339 }
6340
6341 #[test]
6342 fn intent_router_picks_scheduled_tasks_for_task_questions() {
6343 assert_eq!(
6344 preferred_host_inspection_topic("what scheduled tasks are running on this PC?"),
6345 Some("scheduled_tasks")
6346 );
6347 assert_eq!(
6348 preferred_host_inspection_topic("show me the task scheduler"),
6349 Some("scheduled_tasks")
6350 );
6351 }
6352
6353 #[test]
6354 fn intent_router_picks_dev_conflicts_for_conflict_questions() {
6355 assert_eq!(
6356 preferred_host_inspection_topic("are there any dev environment conflicts?"),
6357 Some("dev_conflicts")
6358 );
6359 assert_eq!(
6360 preferred_host_inspection_topic("why is python pointing to the wrong version?"),
6361 Some("dev_conflicts")
6362 );
6363 }
6364
6365 #[test]
6366 fn shell_guard_catches_windows_update_commands() {
6367 assert!(shell_looks_like_structured_host_inspection(
6368 "Get-WindowsUpdateLog | Select-Object -Last 50"
6369 ));
6370 assert!(shell_looks_like_structured_host_inspection(
6371 "$sess = New-Object -ComObject Microsoft.Update.Session"
6372 ));
6373 assert!(shell_looks_like_structured_host_inspection(
6374 "Get-Service wuauserv"
6375 ));
6376 assert!(shell_looks_like_structured_host_inspection(
6377 "Get-MpComputerStatus"
6378 ));
6379 assert!(shell_looks_like_structured_host_inspection(
6380 "Get-PhysicalDisk"
6381 ));
6382 assert!(shell_looks_like_structured_host_inspection(
6383 "Get-CimInstance Win32_Battery"
6384 ));
6385 assert!(shell_looks_like_structured_host_inspection(
6386 "Get-WinEvent -FilterHashtable @{Id=41}"
6387 ));
6388 assert!(shell_looks_like_structured_host_inspection(
6389 "Get-ScheduledTask | Where-Object State -ne Disabled"
6390 ));
6391 }
6392}