nornir 0.5.4

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! 💬 **Chat** tab — a "local Claude" / agent-agnostic chat panel wired to the
//! ACTIVE local model (EPIC H / #45).
//!
//! This is the real interactive surface for nornir's local-model story: type a
//! prompt, hit Send, and the message is completed by an in-process
//! [`Generator`](crate::warehouse::generator::Generator) (candle by default,
//! loading a quantized GGUF from the `/mnt/work4t` model cache — see
//! [`candle::models_root`](crate::warehouse::generator::candle::models_root)). No
//! ollama daemon, no cloud API: the completion runs on this machine.
//!
//! ## Active model
//! The model is a `<backend>:<model>` spec ([`ChatTabState::spec`]). It defaults
//! to [`DEFAULT_MODEL_SPEC`] but honours `$NORNIR_GEN_BACKEND` (the CLI-parity
//! `--gen` fallback) so the operator — or the robot harness — points the chat at
//! any backend/model without a rebuild. The generator is built lazily + cached on
//! the first Send (constructing it is cheap; the multi-GB weight load happens on
//! the first `complete`).
//!
//! ## MCP wiring (agent-agnostic)
//! nornir's MCP server (`nornir-mcp`) is a **stdio relay** — an agent spawns it
//! locally and it relays to a `nornir-server` over gRPC. There is no network MCP
//! endpoint to register. [`mcp_client_config`] emits the EXACT `mcpServers` block
//! (command + env) a local agent (Claude Code / Copilot / any MCP client) uses,
//! and it rides in [`ChatTabState::state_json`] so the config is discoverable, and
//! is written to a file at app start (`<warehouse>/nornir-mcp.json`).
//!
//! ## Robot / introspection
//! [`state_json`](ChatTabState::state_json) exposes the transcript + `last_response`
//! so the robot-UI harness reads back the model's reply as DATA (LAW #6). The
//! live-drive keys `chat.input`/`chat.model`/`chat.system`/`chat.max_tokens` (set)
//! and `chat.send`/`chat.clear` (click) run the SAME code the GUI buttons do.

use eframe::egui::{self, RichText, ScrollArea};

use super::facett_theme::{Theme, RED};
use crate::warehouse::generator::{generator, GenRequest, Generator};

/// The default active model: the RECENT, 16 GB-class local model (Qwen2.5-7B
/// Instruct, Q4_K_M — ~4.7 GB, fits an RTX 5080's ~14 GB free VRAM). Overridable
/// via `$NORNIR_GEN_BACKEND` (CLI-parity with `--gen`).
pub const DEFAULT_MODEL_SPEC: &str = "candle:qwen2.5-7b";

/// Token cap per reply (kept modest so a CPU decode stays interactive).
const DEFAULT_MAX_TOKENS: usize = 256;

/// One transcript turn.
struct ChatMsg {
    /// `"user"` or `"assistant"`.
    role: &'static str,
    text: String,
}

/// The 💬 Chat pane state: the active model spec, the transcript, the in-flight
/// input, and the lazily-built generator (cached across sends).
pub struct ChatTabState {
    /// The active `<backend>:<model>` spec (e.g. `candle:qwen2.5-7b`).
    spec: String,
    /// The paste-in prompt buffer.
    input: String,
    /// Optional system prompt sent with every turn.
    system: String,
    /// Hard cap on generated tokens per reply.
    max_tokens: usize,
    /// The full transcript (user + assistant turns).
    messages: Vec<ChatMsg>,
    /// The most recent assistant reply (what the robot asserts on).
    last_response: Option<String>,
    /// The last error, if the model failed to load / complete.
    error: Option<String>,
    /// The lazily-built + cached generator. `None` until the first send; then
    /// `Some(Ok)` (built) or `Some(Err)` (bad spec / not compiled).
    r#gen: Option<Result<Box<dyn Generator>, String>>,
    theme: Theme,
}

impl Default for ChatTabState {
    fn default() -> Self {
        Self::new()
    }
}

impl ChatTabState {
    /// Build the chat pane with the active model resolved from
    /// `$NORNIR_GEN_BACKEND` (CLI-parity) or [`DEFAULT_MODEL_SPEC`]. Cheap — no
    /// weights are loaded until the first Send.
    pub fn new() -> Self {
        let spec = crate::warehouse::generator::spec_from_env()
            .unwrap_or_else(|| DEFAULT_MODEL_SPEC.to_string());
        Self::with_spec(spec)
    }

    /// Build the chat pane pinned to an explicit model spec (tests / callers that
    /// want a specific backend).
    pub fn with_spec(spec: impl Into<String>) -> Self {
        Self {
            spec: spec.into(),
            input: String::new(),
            system: String::new(),
            max_tokens: DEFAULT_MAX_TOKENS,
            messages: Vec::new(),
            last_response: None,
            error: None,
            r#gen: None,
            theme: Theme::default(),
        }
    }

    /// Set the facett palette the pane paints with.
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// The active model spec (`<backend>:<model>`).
    pub fn spec(&self) -> &str {
        &self.spec
    }

    /// Complete the current [`input`](Self::input) with the active model,
    /// appending the user turn + the assistant reply to the transcript. Runs the
    /// generation SYNCHRONOUSLY (like the Funnel `run_demo` drive path) so the
    /// robot harness reads a deterministic `last_response` right after. Returns the
    /// reply text on success.
    pub fn send(&mut self) -> Result<String, String> {
        let prompt = self.input.trim().to_string();
        if prompt.is_empty() {
            return Err("empty prompt (nothing to send)".into());
        }
        // Build + cache the generator on first use (cheap: no weights loaded here).
        if self.r#gen.is_none() {
            self.r#gen = Some(generator(&self.spec).map_err(|e| e.to_string()));
        }
        // Run the completion; the borrow of `gen` ends with this match so we can
        // then mutate the transcript.
        let result: Result<String, String> = match self.r#gen.as_ref().expect("set above") {
            Err(e) => Err(e.clone()),
            Ok(g) => {
                let mut req = GenRequest::new(prompt.clone()).with_max_tokens(self.max_tokens);
                if !self.system.trim().is_empty() {
                    req = req.with_system(self.system.clone());
                }
                g.complete(&req)
                    .map(|a| a.text.trim().to_string())
                    .map_err(|e| format!("{e:#}"))
            }
        };
        self.messages.push(ChatMsg { role: "user", text: prompt });
        match result {
            Ok(text) => {
                self.messages.push(ChatMsg { role: "assistant", text: text.clone() });
                self.last_response = Some(text.clone());
                self.error = None;
                self.input.clear();
                Ok(text)
            }
            Err(e) => {
                self.error = Some(e.clone());
                Err(e)
            }
        }
    }

    /// **R6** drive-half: set a named chat field on the LIVE viz (CLI/robot parity
    /// with `RobotSession::set_field`). Recognised fields:
    ///
    /// * `input` — the paste-in prompt buffer (text);
    /// * `system` — the system prompt (text);
    /// * `model` — the active `<backend>:<model>` spec (rebuilds the generator);
    /// * `max_tokens` — the per-reply token cap (positive integer).
    pub(crate) fn set_field_for_drive(&mut self, field: &str, value: &str) -> Result<(), String> {
        match field {
            "input" => {
                self.input = value.to_string();
                Ok(())
            }
            "system" => {
                self.system = value.to_string();
                Ok(())
            }
            "model" => {
                self.spec = value.trim().to_string();
                // Drop the cached generator so the next send rebuilds for the new spec.
                self.r#gen = None;
                Ok(())
            }
            "max_tokens" => {
                let n: usize = value
                    .trim()
                    .parse()
                    .map_err(|_| format!("max_tokens must be a positive integer, got {value:?}"))?;
                self.max_tokens = n.clamp(1, 4096);
                Ok(())
            }
            other => Err(format!("unknown chat field {other:?}")),
        }
    }

    /// **R6** drive-half: click a named chat button on the LIVE viz (parity with
    /// `RobotSession::click_by_id`). Recognised buttons:
    ///
    /// * `send` — complete the current input with the active model;
    /// * `clear` — reset the transcript.
    pub(crate) fn click_for_drive(
        &mut self,
        button: &str,
        log: &super::action_log::ActionLog,
    ) -> Result<(), String> {
        match button {
            "send" => {
                let r = self.send();
                match &r {
                    Ok(text) => log.push(
                        super::action_log::Kind::Click,
                        format!("chat send (drive): {} char reply", text.len()),
                    ),
                    Err(e) => log.push(
                        super::action_log::Kind::Error,
                        format!("chat send (drive) failed: {e}"),
                    ),
                }
                r.map(|_| ())
            }
            "clear" => {
                self.messages.clear();
                self.last_response = None;
                self.error = None;
                Ok(())
            }
            other => Err(format!("unknown chat button {other:?}")),
        }
    }

    /// The active model's introspection block (which on-disk file backs it, its
    /// footprint, availability, and CPU/GPU device). Candle-aware; other backends
    /// fall back to a probe of id + availability.
    fn model_detail(&self) -> serde_json::Value {
        #[cfg(feature = "gen-candle")]
        if let Some(model) = self.spec.strip_prefix("candle:") {
            return crate::warehouse::generator::candle::spec_detail(model);
        }
        match generator(&self.spec) {
            Ok(g) => serde_json::json!({ "id": g.id(), "available": g.available() }),
            Err(e) => serde_json::json!({ "id": self.spec, "error": e.to_string() }),
        }
    }

    /// 💬 Chat's slice of `state_json` (LAW #6): the active model, the transcript,
    /// the `last_response` the robot asserts on, and the MCP-to-nornir client
    /// config (so an agent's wiring is discoverable from the UI dump).
    pub fn state_json(&self) -> serde_json::Value {
        let messages: Vec<serde_json::Value> = self
            .messages
            .iter()
            .map(|m| serde_json::json!({ "role": m.role, "text": m.text }))
            .collect();
        let turn_count = self.messages.iter().filter(|m| m.role == "user").count();
        serde_json::json!({
            "model_spec": self.spec,
            "model": self.model_detail(),
            "system": self.system,
            "max_tokens": self.max_tokens,
            "input": self.input,
            "turn_count": turn_count,
            "message_count": self.messages.len(),
            "messages": messages,
            "last_response": self.last_response,
            // The single flag the robot chat test asserts: a non-empty reply came back.
            "responded": self.last_response.as_deref().map(|s| !s.is_empty()).unwrap_or(false),
            "error": self.error,
            "mcp": mcp_state_json(),
            "palette": self.theme.name,
        })
    }

    pub fn draw(&mut self, ui: &mut egui::Ui, log: &super::action_log::ActionLog) {
        let theme = self.theme;
        ui.horizontal(|ui| {
            ui.heading("💬 Local chat");
            ui.separator();
            ui.label(RichText::new(&self.spec).monospace().color(theme.accent));
        });
        ui.label(
            "an agent-agnostic chat wired to the ACTIVE local model — a quantized GGUF loaded \
             in-process (candle), cached under /mnt/work4t. No ollama, no cloud.",
        );

        // ── MCP-to-nornir config (discoverable) ─────────────────────────────────
        egui::CollapsingHeader::new("🔌 MCP → nornir (agent wiring)").show(ui, |ui| {
            ui.label(
                "nornir-mcp is a STDIO relay (no network endpoint): an agent spawns it locally; \
                 it relays to nornir-server over gRPC. Wire it in with:",
            );
            ui.monospace(mcp_add_command());
            ui.add_space(4.0);
            ui.label("or the mcpServers block:");
            ui.monospace(
                serde_json::to_string_pretty(&mcp_client_config()).unwrap_or_default(),
            );
        });
        ui.separator();

        // ── transcript ──────────────────────────────────────────────────────────
        ScrollArea::vertical()
            .auto_shrink([false, false])
            .max_height((ui.available_height() - 90.0).max(60.0))
            .stick_to_bottom(true)
            .show(ui, |ui| {
                if self.messages.is_empty() {
                    ui.weak("(no messages yet — type a prompt below and hit Send)");
                }
                for m in &self.messages {
                    let (who, col) = if m.role == "user" {
                        ("you", theme.text)
                    } else {
                        ("model", theme.accent)
                    };
                    ui.horizontal_wrapped(|ui| {
                        ui.label(RichText::new(format!("{who}: ")).strong().color(col));
                        ui.label(&m.text);
                    });
                    ui.add_space(4.0);
                }
                if let Some(err) = &self.error {
                    ui.colored_label(RED, format!("error: {err}"));
                }
            });

        ui.separator();
        // ── input row ─────────────────────────────────────────────────────────────
        ui.horizontal(|ui| {
            let hint = "ask the local model…";
            let edit = egui::TextEdit::singleline(&mut self.input)
                .hint_text(hint)
                .desired_width(ui.available_width() - 90.0);
            let resp = ui.add(edit);
            let submit = (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
                || ui.button("➤ Send").clicked();
            if submit && !self.input.trim().is_empty() {
                let _ = self.click_for_drive("send", log);
            }
        });
    }
}

// ─── MCP-to-nornir client config (agent-agnostic, discoverable) ──────────────

/// The nornir-server gRPC target an agent's `nornir-mcp` relay points at
/// (`$NORNIR_SERVER`, default `http://localhost:7878`).
fn mcp_server_target() -> String {
    std::env::var("NORNIR_SERVER").unwrap_or_else(|_| "http://localhost:7878".to_string())
}

/// The EXACT `mcpServers` config block a local agent (Claude Code / Copilot / any
/// agent-agnostic MCP client) uses to reach nornir's tools. nornir-mcp is a STDIO
/// relay — the agent spawns it locally; it relays to `nornir-server` over gRPC. So
/// the config is a spawn command + env, NOT a URL.
pub fn mcp_client_config() -> serde_json::Value {
    let token = std::env::var("NORNIR_SERVER_TOKEN").unwrap_or_else(|_| "<token>".to_string());
    serde_json::json!({
        "mcpServers": {
            "nornir": {
                "command": "nornir-mcp",
                "args": [],
                "env": {
                    "NORNIR_SERVER": mcp_server_target(),
                    "NORNIR_SERVER_TOKEN": token,
                }
            }
        }
    })
}

/// The one-liner `claude mcp add` form of [`mcp_client_config`].
pub fn mcp_add_command() -> String {
    format!(
        "claude mcp add nornir --env NORNIR_SERVER={} \
         --env NORNIR_SERVER_TOKEN=<token> -- nornir-mcp",
        mcp_server_target()
    )
}

/// The MCP block for the chat pane's `state_json` (transport + command + the full
/// client config + the add-command), so an agent's wiring is discoverable from the
/// headless UI dump (LAW #6).
pub fn mcp_state_json() -> serde_json::Value {
    serde_json::json!({
        "transport": "stdio",
        "command": "nornir-mcp",
        "server": mcp_server_target(),
        "config": mcp_client_config(),
        "add_command": mcp_add_command(),
        "note": "nornir-mcp is a local stdio relay; it is NOT a network MCP endpoint. \
                 Spawn it beside the agent; it relays to nornir-server over gRPC.",
    })
}

/// Write the MCP client config to a discoverable file (`<dir>/nornir-mcp.json`) so
/// an agent operator can point their MCP client at nornir without hunting. Called
/// at app start with the warehouse root. Best-effort (a write failure is logged by
/// the caller, never fatal).
pub fn write_mcp_config_file(dir: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
    let path = dir.join("nornir-mcp.json");
    let body = serde_json::to_string_pretty(&mcp_client_config()).unwrap_or_default();
    std::fs::write(&path, body)?;
    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mcp_config_is_stdio_relay_not_a_url() {
        let cfg = mcp_client_config();
        // The agent spawns nornir-mcp locally (a command), not a URL endpoint.
        assert_eq!(cfg["mcpServers"]["nornir"]["command"], "nornir-mcp");
        assert!(
            cfg["mcpServers"]["nornir"]["env"]["NORNIR_SERVER"]
                .as_str()
                .unwrap()
                .starts_with("http"),
            "relay points at the nornir-server gRPC target"
        );
        assert!(mcp_add_command().contains("claude mcp add nornir"));
        assert_eq!(mcp_state_json()["transport"], "stdio");
    }

    #[test]
    fn empty_input_send_errors_not_panics() {
        let mut chat = ChatTabState::with_spec("mock");
        let err = chat.send().unwrap_err();
        assert!(err.contains("empty prompt"), "{err}");
        assert!(!chat.state_json()["responded"].as_bool().unwrap());
    }

    #[test]
    fn mock_backend_round_trips_a_reply_into_state_json() {
        // Backend-agnostic proof of the whole insert→send→read-back path using the
        // always-available `mock` generator (no weights): set the input via the R6
        // drive verb, click send, and assert the transcript + last_response.
        let log = super::super::action_log::ActionLog::new();
        let mut chat = ChatTabState::with_spec("mock");
        chat.set_field_for_drive("input", "capital of France?").unwrap();
        chat.click_for_drive("send", &log).unwrap();

        let st = chat.state_json();
        assert_eq!(st["turn_count"], 1);
        assert_eq!(st["message_count"], 2, "one user + one assistant turn");
        assert!(st["responded"].as_bool().unwrap(), "a non-empty reply came back");
        let reply = st["last_response"].as_str().unwrap();
        assert!(reply.contains("capital of France?"), "mock echoes the prompt: {reply}");
        // The input was consumed on send.
        assert_eq!(st["input"], "");
    }

    #[test]
    fn set_model_field_rebuilds_generator() {
        let mut chat = ChatTabState::with_spec("mock:one");
        chat.set_field_for_drive("model", "mock:two").unwrap();
        assert_eq!(chat.spec(), "mock:two");
    }
}