qualia_core_db/inference/inference_agent/local_agent.rs
1// The concrete `LocalLlmAgent` struct, its constructors, LoRA adapter
2// management, sieve-lexicon wiring, and DID hashing. The Phase-8 decode path
3// lives in `decode.rs`; intent validation in `validation.rs`; the
4// `AgentRuntime` impl in `runtime.rs` — all separate impl blocks on this type.
5
6use crate::q_hash;
7
8use super::types::{default_local_modality, AgentBackend};
9
10// ─── LocalLlmAgent ───────────────────────────────────────────────────────────
11/// The concrete local inference agent. Uses a mock inference path for now;
12/// swap `infer_local_model` for an actual llama.cpp FFI call.
13pub struct LocalLlmAgent {
14 pub agent_did: String,
15 pub backend: AgentBackend,
16 pub memory_used_bytes: std::sync::atomic::AtomicU64,
17 // NOTE: the four fields below were private in the monolith. They are now
18 // `pub(super)` widenings so the decode (`decode.rs`) and runtime
19 // (`runtime.rs`) impl blocks can read/write them across the new submodule
20 // boundary. Visibility stays crate-internal — the external API is unchanged.
21 /// Set by `validate_intent` when `output_mode` requires graph-structured emission.
22 pub(super) use_sieve_output: std::sync::atomic::AtomicBool,
23 /// Memory-mapped `.q42.lex` sidecar for dynamic sieve masks.
24 pub(super) sieve_lex_path: std::sync::Mutex<Option<String>>,
25 /// IRI hashes to resolve through the lexicon for Subject / Predicate / Object slots.
26 pub(super) sieve_spec: std::sync::Mutex<crate::neuro_symbolic_sieve::SieveLexSpec>,
27 /// Optional LoRA adapter manager for zero-copy context-driven neural adaptation.
28 /// When set, the prompt is classified into a domain (Medical / Legal / Chemical / …)
29 /// and the matching adapter's delta is applied to the embedding hidden state before
30 /// the autoregressive decode loop.
31 pub(super) lora_manager: std::sync::Mutex<Option<crate::lora::LoRAAdapterManager>>,
32}
33
34impl LocalLlmAgent {
35 pub fn new(agent_did: impl Into<String>, model_path: impl Into<String>) -> Self {
36 Self::with_local_backend(
37 agent_did,
38 AgentBackend::Local {
39 model_path: model_path.into(),
40 context_window: 4096,
41 quantization: "Q4_K_M".into(),
42 vision_projector_path: None,
43 modality: default_local_modality(),
44 architecture: None,
45 },
46 )
47 }
48
49 /// Construct an agent with a fully specified backend (e.g. catalog multimodal profile).
50 pub fn with_local_backend(agent_did: impl Into<String>, backend: AgentBackend) -> Self {
51 Self {
52 agent_did: agent_did.into(),
53 backend,
54 memory_used_bytes: std::sync::atomic::AtomicU64::new(0),
55 use_sieve_output: std::sync::atomic::AtomicBool::new(false),
56 sieve_lex_path: std::sync::Mutex::new(None),
57 sieve_spec: std::sync::Mutex::new(
58 crate::neuro_symbolic_sieve::SieveLexSpec::graph_mutation_default(),
59 ),
60 lora_manager: std::sync::Mutex::new(None),
61 }
62 }
63
64 // ── LoRA adapter management ───────────────────────────────────────────────
65
66 /// Attach a LoRA adapter directory to this agent.
67 ///
68 /// Adapters are loaded lazily on the first prompt that triggers a domain
69 /// switch. The directory must contain `*.lora` files named after
70 /// `ContextType::adapter_filename()` (e.g. `medical_v1.lora`).
71 pub fn attach_lora_adapters(&self, adapter_dir: impl Into<std::path::PathBuf>) {
72 let mgr = crate::lora::LoRAAdapterManager::new(adapter_dir);
73 *self.lora_manager.lock().unwrap_or_else(|e| e.into_inner()) = Some(mgr);
74 }
75
76 /// Attach a LoRA manager pre-configured with expected embedding dimensions.
77 ///
78 /// `n_in` should match the model's embedding dimension (e.g. 4096 for 7B models).
79 pub fn attach_lora_adapters_with_dims(
80 &self,
81 adapter_dir: impl Into<std::path::PathBuf>,
82 n_in: usize,
83 n_out: usize,
84 ) {
85 let mut mgr = crate::lora::LoRAAdapterManager::new(adapter_dir);
86 mgr.set_expected_dims(n_in, n_out);
87 *self.lora_manager.lock().unwrap_or_else(|e| e.into_inner()) = Some(mgr);
88 }
89
90 /// Remove the LoRA manager and revert to base-model-only inference.
91 pub fn detach_lora_adapters(&self) {
92 *self.lora_manager.lock().unwrap_or_else(|e| e.into_inner()) = None;
93 }
94
95 /// Detect context from `prompt` and pre-warm the LoRA adapter cache.
96 ///
97 /// Call this before a batch of related prompts to avoid cold-load latency
98 /// on the first inference.
99 pub fn warm_lora_for_prompt(&self, prompt: &str) {
100 let mut guard = self.lora_manager.lock().unwrap_or_else(|e| e.into_inner());
101 if let Some(mgr) = guard.as_mut() {
102 let (ctx, conf) = mgr.detector.analyze_text(prompt);
103 if conf >= mgr.detector.confidence_threshold {
104 let _ = mgr.switch_to(ctx);
105 }
106 }
107 }
108
109 /// Return the currently active LoRA context type, if any.
110 pub fn active_lora_context(&self) -> Option<crate::lora::ContextType> {
111 let guard = self.lora_manager.lock().unwrap_or_else(|e| e.into_inner());
112 guard
113 .as_ref()
114 .and_then(|m| m.active())
115 .map(|a| a.context_type)
116 }
117
118 /// Wire the `.q42.lex` sidecar used to populate FSM sieve masks at inference time.
119 pub fn configure_sieve_lex(&self, path: impl Into<String>) {
120 *self
121 .sieve_lex_path
122 .lock()
123 .unwrap_or_else(|e| e.into_inner()) = Some(path.into());
124 }
125
126 pub fn agent_did_hash(&self) -> u64 {
127 q_hash(&self.agent_did)
128 }
129}