Skip to main content

hematite/agent/
routing.rs

1use super::conversation::WorkflowMode;
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4pub(crate) enum QueryIntentClass {
5    ProductTruth,
6    RuntimeDiagnosis,
7    RepoArchitecture,
8    Toolchain,
9    Capability,
10    Implementation,
11    Unknown,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum DirectAnswerKind {
16    About,
17    LanguageCapability,
18    UnsafeWorkflowPressure,
19    SessionMemory,
20    RecoveryRecipes,
21    McpLifecycle,
22    AuthorizationPolicy,
23    ToolClasses,
24    ToolRegistryOwnership,
25    SessionResetSemantics,
26    ProductSurface,
27    ReasoningSplit,
28    Identity,
29    WorkflowModes,
30    GemmaNative,
31    GemmaNativeSettings,
32    VerifyProfiles,
33    Toolchain,
34    ArchitectSessionResetPlan,
35}
36
37#[derive(Clone, Copy, Debug)]
38pub(crate) struct QueryIntent {
39    pub(crate) primary_class: QueryIntentClass,
40    pub(crate) direct_answer: Option<DirectAnswerKind>,
41    pub(crate) grounded_trace_mode: bool,
42    pub(crate) capability_mode: bool,
43    pub(crate) capability_needs_repo: bool,
44    pub(crate) toolchain_mode: bool,
45    pub(crate) host_inspection_mode: bool,
46    pub(crate) preserve_project_map_output: bool,
47    pub(crate) architecture_overview_mode: bool,
48}
49
50fn contains_any(haystack: &str, needles: &[&str]) -> bool {
51    needles.iter().any(|needle| haystack.contains(needle))
52}
53
54fn contains_all(haystack: &str, needles: &[&str]) -> bool {
55    needles.iter().all(|needle| haystack.contains(needle))
56}
57
58fn mentions_reset_commands(lower: &str) -> bool {
59    contains_all(lower, &["/clear", "/new", "/forget"])
60}
61
62fn mentions_stable_product_surface(lower: &str) -> bool {
63    contains_any(
64        lower,
65        &[
66            "stable product-surface question",
67            "stable product surface question",
68            "stable product-surface questions",
69            "stable product surface questions",
70        ],
71    )
72}
73
74fn mentions_product_truth_routing(lower: &str) -> bool {
75    let asks_decision_policy = contains_any(
76        lower,
77        &[
78            "how hematite decides",
79            "how does hematite decide",
80            "decides whether",
81            "decide whether",
82        ],
83    );
84    let asks_direct_vs_inspect_split = contains_any(
85        lower,
86        &[
87            "answered as stable product truth",
88            "stable product truth",
89            "stable product behavior",
90            "answer directly",
91            "direct answer",
92            "inspect the repository",
93            "inspect repository",
94            "repository implementation",
95            "repo implementation",
96        ],
97    );
98    asks_decision_policy && asks_direct_vs_inspect_split
99}
100
101fn mentions_broad_system_walkthrough(lower: &str) -> bool {
102    let asks_walkthrough = contains_any(
103        lower,
104        &[
105            "walk me through",
106            "walk through",
107            "how hematite is wired",
108            "understand how hematite is wired",
109            "major runtime pieces",
110            "normal message moves",
111            "moves from the tui to the model and back",
112        ],
113    );
114    let asks_multiple_runtime_areas = contains_any(
115        lower,
116        &[
117            "session recovery",
118            "tool policy",
119            "mcp state",
120            "mcp policy",
121            "files own the major runtime pieces",
122            "which files own",
123            "where session recovery",
124            "where tool policy",
125            "where mcp state",
126        ],
127    );
128    asks_walkthrough && asks_multiple_runtime_areas
129}
130
131fn mentions_capability_question(lower: &str) -> bool {
132    contains_any(
133        lower,
134        &[
135            "what can you do",
136            "what are you capable",
137            "can you make projects",
138            "can you build projects",
139            "do you know other coding languages",
140            "other coding languages",
141            "what languages",
142            "can you use the internet",
143            "internet research capabilities",
144            "what tools do you have",
145        ],
146    )
147}
148
149fn mentions_creator_question(lower: &str) -> bool {
150    contains_any(
151        lower,
152        &[
153            "who created you",
154            "who built you",
155            "who made you",
156            "who developed you",
157            "who engineered you",
158            "who engineered your architecture",
159            "who created hematite",
160            "who built hematite",
161            "who developed hematite",
162            "who engineered hematite",
163            "who maintains hematite",
164            "who authored hematite",
165            "who is the author",
166            "who wrote this",
167            "who made this app",
168        ],
169    )
170}
171
172fn capability_question_requires_repo_inspection(lower: &str) -> bool {
173    contains_any(
174        lower,
175        &[
176            "this repo",
177            "this repository",
178            "codebase",
179            "which files",
180            "implementation",
181            "in this project",
182        ],
183    )
184}
185
186fn mentions_host_inspection_question(lower: &str) -> bool {
187    let host_scope = contains_any(
188        lower,
189        &[
190            "path",
191            "package manager",
192            "package managers",
193            "env doctor",
194            "environment doctor",
195            "pip",
196            "winget",
197            "choco",
198            "scoop",
199            "network",
200            "adapter",
201            "dns",
202            "gateway",
203            "ip address",
204            "ipconfig",
205            "wifi",
206            "ethernet",
207            "service",
208            "services",
209            "daemon",
210            "startup type",
211            "process",
212            "processes",
213            "task manager",
214            "ram",
215            "cpu",
216            "memory",
217            "developer tools",
218            "toolchains",
219            "installed",
220            "desktop",
221            "downloads",
222            "folder",
223            "directory",
224            "local development",
225            "machine",
226            "computer",
227        ],
228    );
229    let host_action = contains_any(
230        lower,
231        &[
232            "inspect",
233            "count",
234            "tell me",
235            "summarize",
236            "how big",
237            "biggest",
238            "versions",
239            "duplicate",
240            "missing",
241            "ready",
242            "fix",
243            "repair",
244            "resolve",
245            "troubleshoot",
246        ],
247    );
248
249    host_scope && host_action
250}
251
252pub(crate) fn preferred_host_inspection_topic(user_input: &str) -> Option<&'static str> {
253    let lower = user_input.to_lowercase();
254    let asks_fix_plan = (lower.contains("fix")
255        || lower.contains("repair")
256        || lower.contains("resolve")
257        || lower.contains("troubleshoot"))
258        && (lower.contains("cargo")
259            || lower.contains("path")
260            || lower.contains("package manager")
261            || lower.contains("toolchain")
262            || lower.contains("port ")
263            || lower.contains("already in use")
264            || lower.contains("lm studio")
265            || lower.contains("localhost:1234")
266            || lower.contains("embedding model")
267            || lower.contains("no coding model loaded"));
268    let asks_path = lower.contains("path");
269    let asks_env_doctor = lower.contains("env doctor")
270        || lower.contains("environment doctor")
271        || lower.contains("package manager")
272        || lower.contains("package managers")
273        || lower.contains("shims")
274        || lower.contains("path drift")
275        || (lower.contains("dev machine") && lower.contains("off"))
276        || (lower.contains("environment") && lower.contains("sane"));
277    let asks_network = lower.contains("network")
278        || lower.contains("adapter")
279        || lower.contains("dns")
280        || lower.contains("gateway")
281        || lower.contains("ip address")
282        || lower.contains("ipconfig")
283        || lower.contains("wifi")
284        || lower.contains("ethernet")
285        || lower.contains("subnet");
286    let asks_services = lower.contains("service")
287        || lower.contains("services")
288        || lower.contains("daemon")
289        || lower.contains("startup type")
290        || lower.contains("background service")
291        || lower.contains("windows service")
292        || lower.contains("systemctl")
293        || lower.contains("get-service");
294    let asks_processes = lower.contains("process")
295        || lower.contains("processes")
296        || lower.contains("task manager")
297        || lower.contains("what is running")
298        || lower.contains("what's running")
299        || lower.contains("using my ram")
300        || lower.contains("using ram")
301        || lower.contains("using my cpu")
302        || lower.contains("top memory")
303        || lower.contains("top ram")
304        || lower.contains("high memory");
305    let asks_toolchains = lower.contains("developer tools")
306        || lower.contains("toolchains")
307        || (lower.contains("installed") && lower.contains("version"))
308        || (lower.contains("detect") && lower.contains("version"));
309    let asks_ports = lower.contains("listening on port")
310        || lower.contains("listening port")
311        || lower.contains("open port")
312        || lower.contains("port 3000")
313        || lower.contains("port ")
314        || lower.contains("listening on ")
315        || lower.contains("exposed")
316        || lower.contains("what is listening");
317    let asks_repo_doctor = lower.contains("repo doctor")
318        || lower.contains("repository doctor")
319        || lower.contains("workspace health")
320        || lower.contains("repo health")
321        || lower.contains("workspace sanity")
322        || (lower.contains("git state")
323            && (lower.contains("release artifacts")
324                || lower.contains("build markers")
325                || lower.contains("hematite memory")));
326    let asks_directory = lower.contains("directory")
327        || lower.contains("folder")
328        || lower.contains("how big")
329        || lower.contains("biggest");
330    let asks_broad_readiness = lower.contains("local development")
331        || lower.contains("ready for local development")
332        || (lower.contains("machine") && lower.contains("ready"))
333        || (lower.contains("computer") && lower.contains("ready"));
334
335    if asks_fix_plan {
336        Some("fix_plan")
337    } else if (asks_path && asks_toolchains)
338        || (mentions_host_inspection_question(&lower) && asks_broad_readiness)
339    {
340        Some("summary")
341    } else if asks_env_doctor {
342        Some("env_doctor")
343    } else if asks_network {
344        Some("network")
345    } else if asks_services {
346        Some("services")
347    } else if asks_processes {
348        Some("processes")
349    } else if asks_ports {
350        Some("ports")
351    } else if asks_repo_doctor {
352        Some("repo_doctor")
353    } else if lower.contains("desktop") {
354        Some("desktop")
355    } else if lower.contains("downloads") {
356        Some("downloads")
357    } else if asks_path {
358        Some("path")
359    } else if asks_toolchains {
360        Some("toolchains")
361    } else if asks_directory {
362        Some("directory")
363    } else if mentions_host_inspection_question(&lower) {
364        Some("summary")
365    } else {
366        None
367    }
368}
369
370pub(crate) fn looks_like_mutation_request(user_input: &str) -> bool {
371    let lower = user_input.to_lowercase();
372    [
373        "fix ",
374        "change ",
375        "edit ",
376        "modify ",
377        "update ",
378        "rename ",
379        "refactor ",
380        "patch ",
381        "rewrite ",
382        "implement ",
383        "create a file",
384        "create file",
385        "add a file",
386        "delete ",
387        "remove ",
388        "make the change",
389    ]
390    .iter()
391    .any(|needle| lower.contains(needle))
392}
393
394pub(crate) fn classify_query_intent(workflow_mode: WorkflowMode, user_input: &str) -> QueryIntent {
395    let lower = user_input.to_lowercase();
396    let trimmed = user_input.trim().to_ascii_lowercase();
397
398    let mentions_runtime_trace = contains_any(
399        &lower,
400        &[
401            "trace",
402            "how does",
403            "what are the main runtime subsystems",
404            "how does a user message move",
405            "separate normal assistant output",
406            "session reset behavior",
407            "file references",
408            "event types",
409            "channels",
410        ],
411    );
412    let anti_guess = contains_any(&lower, &["do not guess", "if you are unsure"]);
413    let capability_mode = mentions_capability_question(&lower);
414    let capability_needs_repo =
415        capability_mode && capability_question_requires_repo_inspection(&lower);
416    let host_inspection_mode = preferred_host_inspection_topic(&lower).is_some();
417    let toolchain_mode = contains_any(
418        &lower,
419        &[
420            "tooling discipline",
421            "best read-only toolchain",
422            "identify the best tools you actually have",
423            "concrete read-only investigation plan",
424            "do not execute the plan",
425            "available repo-inspection tools",
426            "tool choice discipline",
427            "what tools would you choose first",
428        ],
429    ) || (lower.contains("which tools") && lower.contains("why"))
430        || (lower.contains("when would you choose") && lower.contains("tool"));
431    let preserve_project_map_output = lower.contains("map_project")
432        || lower.contains("entrypoint")
433        || lower.contains("owner file")
434        || lower.contains("owner files")
435        || lower.contains("project structure")
436        || lower.contains("repository structure")
437        || (lower.contains("architecture")
438            && (lower.contains("repo") || lower.contains("repository")));
439    let architecture_overview_mode = {
440        let architecture_signals = contains_any(
441            &lower,
442            &[
443                "architecture walkthrough",
444                "full architecture",
445                "runtime walkthrough",
446                "control flow",
447                "tool routing",
448                "workflow modes",
449                "repo map behavior",
450                "mcp policy",
451                "prompt budgeting",
452                "compaction",
453                "file ownership",
454                "owner files",
455            ],
456        );
457        let broad = contains_any(
458            &lower,
459            &[
460                "full detailed",
461                "all in one answer",
462                "concrete file ownership",
463                "walk me through",
464                "major runtime pieces",
465                "which files own",
466            ],
467        );
468        (architecture_signals && broad)
469            || (lower.contains("runtime")
470                && lower.contains("workflow")
471                && (lower.contains("architecture") || lower.contains("tool routing")))
472            || mentions_broad_system_walkthrough(&lower)
473    };
474
475    let direct_answer = if trimmed == "/about" || mentions_creator_question(&lower) {
476        Some(DirectAnswerKind::About)
477    } else if matches!(
478        trimmed.as_str(),
479        "who are you" | "who are you?" | "what are you" | "what are you?"
480    ) || (lower.contains("what is hematite") && !lower.contains("lm studio"))
481    {
482        Some(DirectAnswerKind::Identity)
483    } else if (mentions_stable_product_surface(&lower) || mentions_product_truth_routing(&lower))
484        && contains_any(
485            &lower,
486            &[
487                "how hematite answers",
488                "how does hematite answer",
489                "how hematite handles",
490                "how does hematite handle",
491                "how hematite decides",
492                "how does hematite decide",
493                "decides whether",
494                "decide whether",
495            ],
496        )
497    {
498        Some(DirectAnswerKind::ProductSurface)
499    } else if mentions_reset_commands(&lower)
500        && contains_any(
501            &lower,
502            &[
503                "exact difference",
504                "difference between",
505                "explain the exact difference",
506                "what is the difference",
507            ],
508        )
509    {
510        Some(DirectAnswerKind::SessionResetSemantics)
511    } else if (lower.contains("reasoning output") || lower.contains("reasoning"))
512        && contains_any(
513            &lower,
514            &["visible chat output", "visible chat", "chat output"],
515        )
516    {
517        Some(DirectAnswerKind::ReasoningSplit)
518    } else if lower.contains("/ask")
519        && lower.contains("/code")
520        && lower.contains("/architect")
521        && lower.contains("/read-only")
522        && lower.contains("/auto")
523        && contains_any(&lower, &["difference", "differences", "what are"])
524    {
525        Some(DirectAnswerKind::WorkflowModes)
526    } else if lower.contains(".hematite/settings.json")
527        && lower.contains("gemma_native_auto")
528        && lower.contains("gemma_native_formatting")
529    {
530        Some(DirectAnswerKind::GemmaNativeSettings)
531    } else if contains_any(
532        &lower,
533        &[
534            "skip verification",
535            "skip build verification",
536            "commit it immediately",
537            "commit immediately",
538        ],
539    ) && contains_any(
540        &lower,
541        &[
542            "make a code change",
543            "make the change",
544            "change the code",
545            "edit the code",
546            "edit a file",
547            "implement",
548        ],
549    ) {
550        Some(DirectAnswerKind::UnsafeWorkflowPressure)
551    } else if contains_any(&lower, &["/gemma-native", "gemma native"])
552        && contains_any(&lower, &["what does", "what is", "how does", "what do"])
553    {
554        Some(DirectAnswerKind::GemmaNative)
555    } else if lower.contains("verify_build")
556        && lower.contains(".hematite/settings.json")
557        && contains_any(
558            &lower,
559            &["build", "test", "lint", "fix", "verification commands"],
560        )
561    {
562        Some(DirectAnswerKind::VerifyProfiles)
563    } else if (lower.contains("carry forward by default")
564        || lower.contains("session memory should you carry forward")
565        || (lower.contains("carry forward")
566            && contains_any(
567                &lower,
568                &[
569                    "besides the active task",
570                    "blocker",
571                    "compacts",
572                    "recovers from a blocker",
573                    "session state",
574                ],
575            )))
576        && contains_any(
577            &lower,
578            &[
579                "restarted hematite",
580                "restarted",
581                "avoid carrying forward",
582                "session state",
583                "active task",
584                "blocker",
585                "compacts",
586                "recovers from a blocker",
587            ],
588        )
589    {
590        Some(DirectAnswerKind::SessionMemory)
591    } else if contains_any(
592        &lower,
593        &[
594            "recovery recipe",
595            "recovery recipes",
596            "recovery step",
597            "recovery steps",
598        ],
599    ) && contains_any(
600        &lower,
601        &[
602            "blocker",
603            "runtime failure",
604            "degrades",
605            "context window",
606            "context-window",
607            "operator",
608        ],
609    ) {
610        Some(DirectAnswerKind::RecoveryRecipes)
611    } else if !architecture_overview_mode
612        && contains_any(
613            &lower,
614            &[
615                "mcp server health",
616                "mcp runtime state",
617                "mcp lifecycle",
618                "mcp state",
619                "mcp healthy",
620                "mcp degraded",
621                "mcp failed",
622            ],
623        )
624    {
625        Some(DirectAnswerKind::McpLifecycle)
626    } else if contains_any(
627        &lower,
628        &[
629            "allowed, denied, or require approval",
630            "allowed denied or require approval",
631            "allow, ask, or deny",
632            "tool call should be allowed",
633            "authorization logic",
634            "workspace trust",
635            "trust-allowlisted",
636        ],
637    ) {
638        Some(DirectAnswerKind::AuthorizationPolicy)
639    } else if contains_any(
640        &lower,
641        &[
642            "tool classes",
643            "tool class",
644            "flat tool list",
645            "runtime tool classes",
646            "different runtime tool classes",
647        ],
648    ) || (lower.contains("repo reads")
649        && lower.contains("repo writes")
650        && contains_any(
651            &lower,
652            &[
653                "verification tools",
654                "git tools",
655                "external mcp tools",
656                "different runtime",
657            ],
658        ))
659    {
660        Some(DirectAnswerKind::ToolClasses)
661    } else if contains_any(
662        &lower,
663        &[
664            "built-in tool catalog",
665            "builtin tool catalog",
666            "builtin-tool dispatch",
667            "built-in tool dispatch",
668            "tool registry ownership",
669            "which file now owns",
670        ],
671    ) && contains_any(
672        &lower,
673        &[
674            "tool catalog",
675            "dispatch path",
676            "dispatch",
677            "tool registry",
678            "owns",
679        ],
680    ) {
681        Some(DirectAnswerKind::ToolRegistryOwnership)
682    } else if (lower.contains("other coding languages")
683        || lower.contains("what languages")
684        || lower.contains("know other languages"))
685        && contains_any(
686            &lower,
687            &[
688                "capable of making projects",
689                "can you make projects",
690                "can you build projects",
691            ],
692        )
693    {
694        Some(DirectAnswerKind::LanguageCapability)
695    } else if workflow_mode == WorkflowMode::Architect
696        && (lower.contains("session reset")
697            || (lower.contains("/clear") && lower.contains("/new") && lower.contains("/forget")))
698        && contains_any(&lower, &["redesign", "clearer", "easier", "understand"])
699    {
700        Some(DirectAnswerKind::ArchitectSessionResetPlan)
701    } else if toolchain_mode
702        && lower.contains("read-only")
703        && contains_any(
704            &lower,
705            &[
706                "tooling discipline",
707                "investigation plan",
708                "best read-only toolchain",
709                "tool choice discipline",
710                "what tools would you choose first",
711            ],
712        )
713    {
714        Some(DirectAnswerKind::Toolchain)
715    } else {
716        None
717    };
718
719    let primary_class = if direct_answer.is_some()
720        || mentions_stable_product_surface(&lower)
721        || mentions_product_truth_routing(&lower)
722    {
723        QueryIntentClass::ProductTruth
724    } else if architecture_overview_mode || preserve_project_map_output {
725        QueryIntentClass::RepoArchitecture
726    } else if toolchain_mode {
727        QueryIntentClass::Toolchain
728    } else if capability_mode {
729        QueryIntentClass::Capability
730    } else if mentions_runtime_trace || anti_guess || lower.contains("read-only") {
731        QueryIntentClass::RuntimeDiagnosis
732    } else if looks_like_mutation_request(user_input) {
733        QueryIntentClass::Implementation
734    } else {
735        QueryIntentClass::Unknown
736    };
737
738    QueryIntent {
739        primary_class,
740        direct_answer,
741        grounded_trace_mode: mentions_runtime_trace || lower.contains("read-only") || anti_guess,
742        capability_mode,
743        capability_needs_repo,
744        toolchain_mode,
745        host_inspection_mode,
746        preserve_project_map_output,
747        architecture_overview_mode,
748    }
749}
750
751pub(crate) fn is_capability_probe_tool(name: &str) -> bool {
752    matches!(
753        name,
754        "map_project"
755            | "read_file"
756            | "inspect_lines"
757            | "list_files"
758            | "grep_files"
759            | "lsp_definitions"
760            | "lsp_references"
761            | "lsp_hover"
762            | "lsp_search_symbol"
763            | "lsp_get_diagnostics"
764            | "trace_runtime_flow"
765            | "auto_pin_context"
766            | "list_pinned"
767    )
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn classify_query_intent_routes_creator_questions_to_about() {
776        let intent = classify_query_intent(WorkflowMode::Auto, "Who created Hematite?");
777        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::About));
778
779        let intent = classify_query_intent(WorkflowMode::Auto, "/about");
780        assert_eq!(intent.direct_answer, Some(DirectAnswerKind::About));
781    }
782}