Skip to main content

trusty_memory/chat/
handler.rs

1//! The streaming chat handler (`chat_stream`).
2//!
3//! Why: the OpenRouter/Ollama tool-calling loop is by far the largest single
4//! concern in the chat surface; isolating it keeps the other handlers readable
5//! (split out of the former monolithic `chat.rs`, issue #607).
6//!
7//! Why it streams at all (#6286): this handler used to answer
8//! `POST /api/v1/chat` with `Content-Type: text/event-stream`, pushing LLM
9//! tokens off a `ReceiverStream` as the model produced them. ADR-0032 retired
10//! that listener, and a one-frame-per-connection JSON-RPC call cannot carry a
11//! token stream — so `trusty_common::uds::server`'s multi-frame extension
12//! landed for exactly this method rather than chat degrading to a single
13//! buffered blob. The producer shape is unchanged: a background task fills an
14//! `mpsc::Sender` and this function hands back the receiver.
15//!
16//! What: [`chat_stream`], registered as `memory.chat` with
17//! `RpcRouter::typed_stream`. Each `data: {…}` line the SSE version wrote is
18//! now one `"stream":"item"` frame carrying the same object — `{session_id}`,
19//! `{delta}`, `{tool_call}`, `{tool_result}`. `data: [DONE]` becomes the
20//! stream's terminal `end` frame.
21//!
22//! **A mid-stream provider failure is the terminal ERROR frame, not an item.**
23//! The SSE version wrote `data: {"error": …}` and then ended normally, which a
24//! reader could not tell from a completed answer — the Fail-Open branch
25//! `rpc_chat_reports_a_provider_failure_as_the_terminal_error_frame` closes.
26//!
27//! Tool-loop building blocks (`all_tools`, `execute_tool`, `MAX_TOOL_ROUNDS`,
28//! `ChatBody`) come from the sibling `tools` submodule.
29//! Test: `crate::transport::uds::tests` — `rpc_chat_*`.
30
31use crate::service::load_user_config;
32use crate::AppState;
33use serde_json::{json, Value};
34use tokio::sync::mpsc;
35use trusty_common::memory_core::palace::PalaceId;
36use trusty_common::memory_core::retrieval::recall_with_default_embedder;
37use trusty_common::memory_core::PalaceRegistry;
38use trusty_common::uds::server::{RpcError, RpcStreamItems};
39use trusty_common::{ChatEvent, ChatMessage};
40
41// ---------------------------------------------------------------------------
42
43use super::tools::{all_tools, execute_get_dream_status, execute_tool, ChatBody, MAX_TOOL_ROUNDS};
44
45/// Open a `memory.chat` stream (#6286).
46///
47/// # Errors
48///
49/// Before any item is produced: no chat provider is configured, or the
50/// palace's session store will not open. Both become the stream's terminal
51/// error frame with zero items ahead of it, which is what an `Err` from
52/// `RpcStreamMethod::call` means.
53///
54/// Test: `rpc_chat_refuses_a_unary_call_naming_the_stream_requirement`,
55/// `rpc_chat_reports_a_provider_failure_as_the_terminal_error_frame`.
56pub async fn chat_stream(state: &AppState, body: ChatBody) -> Result<RpcStreamItems, RpcError> {
57    let state = state.clone();
58    // Select the active provider (Ollama auto-detect, else OpenRouter).
59    let Some(provider) = state.chat_provider().await else {
60        return Err(RpcError::internal(
61            "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
62        ));
63    };
64
65    // Resolve palace id (explicit > default).
66    let palace_id = body
67        .palace_id
68        .clone()
69        .or_else(|| state.default_palace.clone())
70        .unwrap_or_default();
71
72    // Resolve / create chat session when a palace is bound.
73    let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
74        let store = match state.session_store(&palace_id) {
75            Ok(s) => s,
76            Err(e) => {
77                tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
78                return Err(RpcError::internal(format!("session store: {e:#}")));
79            }
80        };
81        match body.session_id.clone() {
82            Some(sid) => match store.get_session(&sid) {
83                Ok(Some(s)) => (
84                    Some(sid),
85                    s.history
86                        .into_iter()
87                        .map(|m| ChatMessage {
88                            role: m.role,
89                            content: m.content,
90                            tool_call_id: None,
91                            tool_calls: None,
92                        })
93                        .collect(),
94                ),
95                _ => (Some(sid), body.history.clone()),
96            },
97            None => {
98                let new_id = store.create_session(None).unwrap_or_else(|e| {
99                    tracing::warn!("create_session failed: {e:#}");
100                    String::new()
101                });
102                (
103                    if new_id.is_empty() {
104                        None
105                    } else {
106                        Some(new_id)
107                    },
108                    body.history.clone(),
109                )
110            }
111        }
112    } else {
113        (None, body.history.clone())
114    };
115
116    // Full palace roster for the identity block — names + ids, not just count,
117    // so the model can pick the right one when the user names a palace.
118    let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
119    let palace_count = all_palaces.len();
120    let palace_roster: String = all_palaces
121        .iter()
122        .map(|p| format!("- {} (id: {})", p.name, p.id.0))
123        .collect::<Vec<_>>()
124        .join("\n");
125
126    // Config + global dream snapshot — give the model an honest view of what's
127    // available so it doesn't invent tools or providers that aren't there.
128    let cfg = load_user_config().unwrap_or_default();
129    let active_provider_name = state
130        .chat_provider()
131        .await
132        .map(|p| p.name().to_string())
133        .unwrap_or_else(|| "none".to_string());
134    let dream_snapshot = execute_get_dream_status(&state).await;
135
136    // Look up the selected palace's metadata (name/description) and open its
137    // handle for live counts + recall context.
138    let selected_palace_meta = if palace_id.is_empty() {
139        None
140    } else {
141        all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
142    };
143
144    let mut palace_block = String::new();
145    let mut context = String::new();
146    let mut palace_display_name = palace_id.clone();
147
148    if !palace_id.is_empty() {
149        if let Ok(handle) = state
150            .registry
151            .open_palace(&state.data_root, &PalaceId::new(&palace_id))
152        {
153            // Live counts from the opened handle.
154            let drawer_count = handle.drawers.read().len();
155            let vector_count = handle.vector_store.index_size();
156            // #5384: the prompt claims to be an honest view of the palace, so a
157            // failed count says so rather than printing a 0 the model repeats.
158            let kg_triple_count = match handle.kg.count_active_triples() {
159                Ok(n) => n.to_string(),
160                Err(e) => {
161                    tracing::warn!(palace = %palace_id, "kg_triple_count unavailable: {e:#}");
162                    "unknown (read failed)".to_string()
163                }
164            };
165
166            // Prefer the on-disk palace.json name/description; fall back to id.
167            let (name, description) = match &selected_palace_meta {
168                Some(p) => (p.name.clone(), p.description.clone()),
169                None => (palace_id.clone(), None),
170            };
171            palace_display_name = name.clone();
172
173            palace_block.push_str(&format!(
174                "Currently selected palace:\n\
175                 - id: {id}\n\
176                 - name: {name}\n",
177                id = palace_id,
178                name = name,
179            ));
180            if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
181                palace_block.push_str(&format!("- description: {desc}\n"));
182            }
183            palace_block.push_str(&format!(
184                "- drawers: {drawer_count}\n\
185                 - vectors: {vector_count}\n\
186                 - kg_triples: {kg_triple_count}\n",
187            ));
188            let identity_trimmed = handle.identity.trim();
189            if !identity_trimmed.is_empty() {
190                palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
191            }
192
193            if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
194                for r in hits.iter().take(5) {
195                    context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content()));
196                }
197            }
198        }
199    }
200
201    // Build the grounded system prompt with identity, palace, RAG, config,
202    // dream-snapshot, and behavior blocks so the LLM never confuses
203    // trusty-memory palaces with real-world architectural palaces.
204    let mut system = String::new();
205    system.push_str(&format!(
206        "You are the assistant for trusty-memory, a machine-wide AI memory \
207         service running locally on this user's machine. trusty-memory stores \
208         knowledge in named \"palaces\" — isolated memory namespaces, each with \
209         its own vector index (usearch HNSW) and temporal knowledge graph \
210         (redb). Memories are organized as Palace -> Wing -> Room -> Drawer, \
211         where a Drawer is an atomic memory unit. A \"closet\" is not a level \
212         in that hierarchy — it is a keyword index over drawers.\n\
213         There are currently {palace_count} palace(s) on this machine.\n",
214    ));
215    if !palace_roster.is_empty() {
216        system.push_str(&format!("Palaces:\n{palace_roster}\n"));
217    }
218    system.push('\n');
219
220    // Config block — what providers/models are wired up right now.
221    system.push_str(&format!(
222        "System configuration:\n\
223         - active chat provider: {active_provider_name}\n\
224         - openrouter model: {or_model}\n\
225         - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
226         - data root: {data_root}\n\n",
227        or_model = cfg.openrouter_model,
228        local_model = cfg.local_model.model,
229        local_url = cfg.local_model.base_url,
230        local_enabled = cfg.local_model.enabled,
231        data_root = state.data_root.display(),
232    ));
233
234    // Dream snapshot — give the model a sense of how stale memory state is.
235    system.push_str(&format!(
236        "Global dream status (background memory maintenance):\n{}\n\n",
237        dream_snapshot,
238    ));
239
240    if !palace_block.is_empty() {
241        system.push_str(&palace_block);
242        system.push('\n');
243    }
244
245    if !context.is_empty() {
246        system.push_str(&format!(
247            "Relevant memories from the '{palace_display_name}' palace \
248             (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
249             {context}\n",
250        ));
251    }
252
253    system.push_str(
254        "You have a set of tools to introspect and modify this trusty-memory \
255         daemon. Prefer calling a tool over guessing — e.g. call \
256         `list_palaces` rather than relying on the roster above if you need \
257         live counts, and call `recall_memories` to search for facts you \
258         don't have in context. When the user asks about \"palaces\", they \
259         mean trusty-memory palaces (memory namespaces on this machine), not \
260         architectural palaces like Versailles. If a tool returns an error, \
261         report it honestly and don't fabricate results.",
262    );
263
264    // Append the new user message to the in-memory history we'll persist.
265    history.push(ChatMessage {
266        role: "user".to_string(),
267        content: body.message.clone(),
268        tool_call_id: None,
269        tool_calls: None,
270    });
271
272    let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
273    messages.push(ChatMessage {
274        role: "system".to_string(),
275        content: system,
276        tool_call_id: None,
277        tool_calls: None,
278    });
279    messages.extend(history.iter().cloned());
280
281    let tools = all_tools();
282    // Same capacity the SSE channel had: enough that a fast model does not
283    // block on a reader one frame behind, small enough that a reader which
284    // stopped reading stops the producer rather than buffering forever.
285    let (frames, items) = mpsc::channel::<Result<Value, RpcError>>(64);
286
287    // Capture session-persistence inputs.
288    let session_store = if !palace_id.is_empty() && session_id.is_some() {
289        state.session_store(&palace_id).ok()
290    } else {
291        None
292    };
293    let persist_session_id = session_id.clone();
294
295    // Drive the tool-execution loop in a background task so the response can
296    // start streaming immediately.
297    let loop_state = state.clone();
298    tokio::spawn(async move {
299        // Emit a leading session_id frame so the SPA can correlate this stream
300        // with a persisted session row.
301        if let Some(sid) = persist_session_id.as_deref() {
302            if frames.send(Ok(json!({ "session_id": sid }))).await.is_err() {
303                return;
304            }
305        }
306
307        let mut final_assistant_text = String::new();
308        let mut stream_err: Option<String> = None;
309
310        for round in 0..MAX_TOOL_ROUNDS {
311            let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
312            let messages_clone = messages.clone();
313            let tools_clone = tools.clone();
314            let provider_clone = provider.clone();
315            let stream_handle = tokio::spawn(async move {
316                provider_clone
317                    .chat_stream(messages_clone, tools_clone, event_tx)
318                    .await
319            });
320
321            let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
322            let mut round_assistant_text = String::new();
323
324            while let Some(event) = event_rx.recv().await {
325                match event {
326                    ChatEvent::Delta(text) => {
327                        round_assistant_text.push_str(&text);
328                        // A send failure is the client having hung up: the
329                        // connection handler drops the receiver on a write
330                        // error, which is how the producer learns to stop.
331                        if frames.send(Ok(json!({ "delta": text }))).await.is_err() {
332                            return;
333                        }
334                    }
335                    ChatEvent::ToolCall(tc) => {
336                        let _ = frames
337                            .send(Ok(json!({ "tool_call": {
338                                "id": tc.id,
339                                "name": tc.name,
340                                "arguments": tc.arguments,
341                            }})))
342                            .await;
343                        tool_calls_this_round.push(tc);
344                    }
345                    ChatEvent::Done => break,
346                    // #3767: no usage-accounting consumer wired up here yet;
347                    // ignore rather than let the match go non-exhaustive.
348                    ChatEvent::Usage(_) => {}
349                    ChatEvent::Error(e) => {
350                        stream_err = Some(e);
351                        break;
352                    }
353                    // `ChatEvent` is `#[non_exhaustive]` (trusty-common 0.27.0): a wildcard
354                    // keeps a future variant from breaking this crate's build.
355                    _ => {}
356                }
357            }
358
359            // Drain the spawned stream task; surface any error.
360            match stream_handle.await {
361                Ok(Ok(())) => {}
362                Ok(Err(e)) => stream_err = Some(e.to_string()),
363                Err(e) => stream_err = Some(format!("join: {e}")),
364            }
365
366            if stream_err.is_some() {
367                break;
368            }
369
370            final_assistant_text.push_str(&round_assistant_text);
371
372            if tool_calls_this_round.is_empty() {
373                // Model produced a plain answer — we're done.
374                break;
375            }
376
377            // Build the assistant message that requested these tool calls.
378            let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
379                .iter()
380                .map(|tc| {
381                    json!({
382                        "id": tc.id,
383                        "type": "function",
384                        "function": { "name": tc.name, "arguments": tc.arguments },
385                    })
386                })
387                .collect();
388            messages.push(ChatMessage {
389                role: "assistant".to_string(),
390                content: round_assistant_text,
391                tool_call_id: None,
392                tool_calls: Some(assistant_tool_calls_json),
393            });
394
395            // Execute each tool and append its result as a `role: "tool"`
396            // message. The next loop iteration feeds these back to the model.
397            for tc in &tool_calls_this_round {
398                let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
399                let result_str = result.to_string();
400                let _ = frames
401                    .send(Ok(json!({ "tool_result": {
402                        "id": tc.id,
403                        "name": tc.name,
404                        "content": &result_str,
405                    }})))
406                    .await;
407                messages.push(ChatMessage {
408                    role: "tool".to_string(),
409                    content: result_str,
410                    tool_call_id: Some(tc.id.clone()),
411                    tool_calls: None,
412                });
413            }
414
415            // Safety net: log when we walk off the round limit.
416            if round + 1 == MAX_TOOL_ROUNDS {
417                tracing::warn!(
418                    "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
419                    MAX_TOOL_ROUNDS
420                );
421            }
422        }
423
424        // Persist the completed conversation regardless of streaming error
425        // (partial assistant reply still better than nothing).
426        if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
427            if !final_assistant_text.is_empty() {
428                history.push(ChatMessage {
429                    role: "assistant".into(),
430                    content: final_assistant_text,
431                    tool_call_id: None,
432                    tool_calls: None,
433                });
434            }
435            let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
436                history
437                    .iter()
438                    .map(
439                        |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
440                            attachments: vec![],
441                            role: m.role.clone(),
442                            content: m.content.clone(),
443                        },
444                    )
445                    .collect();
446            if let Err(e) = store.upsert_session(sid, &core_history) {
447                tracing::warn!("upsert_session failed: {e:#}");
448            }
449        }
450
451        // The conversation is persisted above whether or not the provider
452        // failed, so a partial answer is not lost. What differs is how the
453        // stream ENDS: a failure is the terminal error frame, so a reader
454        // cannot mistake a cut-off answer for a finished one. Dropping
455        // `frames` without sending is the success path — `write_stream` reads
456        // the closed channel as the `end` frame.
457        if let Some(e) = stream_err {
458            let _ = frames.send(Err(RpcError::internal(e))).await;
459        }
460    });
461
462    Ok(items)
463}