Skip to main content

trusty_memory/chat/
tools.rs

1//! Chat tool definitions + the `execute_*` dispatcher set.
2//!
3//! Why: the chat assistant's tool surface (`all_tools`) and the in-process
4//! dispatcher that runs each tool (`execute_tool` + the per-tool `execute_*`
5//! functions) form one cohesive concern split out of the former monolithic
6//! `chat.rs` (issue #607).
7//! What: `ChatBody`, `MAX_TOOL_ROUNDS`, `all_tools`, `execute_tool`, and every
8//! `execute_*` helper, moved verbatim. Visibility unchanged.
9//! Test: `all_tools_returns_expected_set`,
10//! `execute_tool_dispatches_known_tools` in `tools_tests.rs`.
11
12use crate::kg_write::CachePolicy;
13use crate::service::helpers::{collect_palace_stats, list_palaces_blocking};
14use crate::service::recall_stream::recall_streamed;
15use crate::service::{load_user_config, palace_info_from, DreamStatusPayload};
16use crate::AppState;
17use serde::Deserialize;
18use serde_json::{json, Value};
19use trusty_common::memory_core::dream::PersistedDreamStats;
20use trusty_common::memory_core::palace::{PalaceId, RoomType};
21use trusty_common::memory_core::retrieval::{
22    recall_across_palaces_with_default_embedder, recall_with_default_embedder,
23};
24use trusty_common::memory_core::store::kg::Triple;
25use trusty_common::memory_core::PalaceRegistry;
26use trusty_common::{ChatMessage, ToolDef};
27
28// ---------------------------------------------------------------------------
29
30#[derive(Deserialize)]
31pub struct ChatBody {
32    #[serde(default)]
33    pub palace_id: Option<String>,
34    pub message: String,
35    #[serde(default)]
36    pub history: Vec<ChatMessage>,
37    /// Optional existing chat-session id; when provided we load+append+save.
38    #[serde(default)]
39    pub session_id: Option<String>,
40}
41
42/// Hard cap on the number of `tool -> assistant` round trips per chat turn.
43///
44/// Why: Without a bound, a malicious or confused model could request tools
45/// indefinitely; 10 is generous enough for any realistic plan-and-act loop
46/// while still terminating quickly when the model gets stuck.
47pub(crate) const MAX_TOOL_ROUNDS: usize = 10;
48
49/// Build the complete set of tool definitions the chat assistant can call.
50///
51/// Why: Centralizing the tool surface keeps the wire schema, the dispatcher in
52/// `execute_tool`, and the system prompt in lock-step — adding a new tool means
53/// editing this one function plus a match arm.
54/// What: Returns the 11 read/write tools spanning palace introspection,
55/// memory recall/create, KG read/write, and daemon status.
56/// Test: `all_tools_returns_expected_set` asserts names and required-arg shape.
57pub(crate) fn all_tools() -> Vec<ToolDef> {
58    vec![
59        ToolDef {
60            name: "list_palaces".into(),
61            description: "List all memory palaces on this machine with their metadata (id, name, description, counts).".into(),
62            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
63        },
64        ToolDef {
65            name: "get_palace".into(),
66            description: "Get details for a specific palace by id.".into(),
67            parameters: json!({
68                "type": "object",
69                "properties": { "palace_id": { "type": "string", "description": "Palace id (kebab-case)" } },
70                "required": ["palace_id"],
71            }),
72        },
73        ToolDef {
74            name: "recall_memories".into(),
75            description: "Semantic search for memories in a palace. Returns the top-k most relevant drawers ranked by similarity to the query.".into(),
76            parameters: json!({
77                "type": "object",
78                "properties": {
79                    "palace_id": { "type": "string" },
80                    "query": { "type": "string", "description": "Free-text query" },
81                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5 }
82                },
83                "required": ["palace_id", "query"],
84            }),
85        },
86        ToolDef {
87            name: "list_drawers".into(),
88            description: "List all drawers (memories) in a palace, most recent first.".into(),
89            parameters: json!({
90                "type": "object",
91                "properties": { "palace_id": { "type": "string" } },
92                "required": ["palace_id"],
93            }),
94        },
95        ToolDef {
96            name: "kg_query".into(),
97            description: "Query the temporal knowledge graph for all currently-active triples whose subject matches.".into(),
98            parameters: json!({
99                "type": "object",
100                "properties": {
101                    "palace_id": { "type": "string" },
102                    "subject": { "type": "string" }
103                },
104                "required": ["palace_id", "subject"],
105            }),
106        },
107        ToolDef {
108            name: "get_config".into(),
109            description: "Get the trusty-memory daemon's configuration (provider, model, data root). API keys are masked.".into(),
110            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
111        },
112        ToolDef {
113            name: "get_status".into(),
114            description: "Get daemon health: version, palace count, totals for drawers/vectors/triples.".into(),
115            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
116        },
117        ToolDef {
118            name: "get_dream_status".into(),
119            description: "Get aggregated dreamer activity across all palaces (merged/pruned/compacted counts, last run timestamp).".into(),
120            parameters: json!({ "type": "object", "properties": {}, "required": [] }),
121        },
122        ToolDef {
123            name: "get_palace_dream_status".into(),
124            description: "Get dreamer activity stats for a specific palace.".into(),
125            parameters: json!({
126                "type": "object",
127                "properties": { "palace_id": { "type": "string" } },
128                "required": ["palace_id"],
129            }),
130        },
131        ToolDef {
132            name: "create_memory".into(),
133            description: "Store a new memory (drawer) in a palace. The content is embedded and inserted into the vector index plus the drawer table.".into(),
134            parameters: json!({
135                "type": "object",
136                "properties": {
137                    "palace_id": { "type": "string" },
138                    "content": { "type": "string", "description": "Verbatim memory text" },
139                    "room": { "type": "string", "description": "Room name (Frontend/Backend/Testing/Planning/Documentation/Research/Configuration/Meetings/General or a custom name); defaults to General." },
140                    "tags": { "type": "array", "items": { "type": "string" } },
141                    "importance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.5 }
142                },
143                "required": ["palace_id", "content"],
144            }),
145        },
146        ToolDef {
147            name: "kg_assert".into(),
148            description: "Assert a knowledge-graph triple. Any prior active triple with the same (subject, predicate) is closed out (valid_to set to now) before the new one is inserted.".into(),
149            parameters: json!({
150                "type": "object",
151                "properties": {
152                    "palace_id": { "type": "string" },
153                    "subject": { "type": "string" },
154                    "predicate": { "type": "string" },
155                    "object": { "type": "string" },
156                    "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0 }
157                },
158                "required": ["palace_id", "subject", "predicate", "object"],
159            }),
160        },
161        ToolDef {
162            name: "memory_recall_all".into(),
163            description: "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.".into(),
164            parameters: json!({
165                "type": "object",
166                "properties": {
167                    "q": { "type": "string", "description": "Free-text query" },
168                    "top_k": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
169                    "deep": { "type": "boolean", "default": false }
170                },
171                "required": ["q"],
172            }),
173        },
174    ]
175}
176
177/// Execute a tool call against the live `AppState`.
178///
179/// Why: We want the model's tool invocations to call the same Rust paths the
180/// HTTP handlers use — no extra HTTP round-trip, no JSON re-parsing, and the
181/// results always reflect this daemon's view of the world.
182/// What: Parses `arguments` as JSON, dispatches by tool name, returns a JSON
183/// value that becomes the `role: "tool"` message content. Errors are caught
184/// and returned as `{"error": "..."}` JSON so the model can react.
185/// Test: `execute_tool_dispatches_known_tools` covers the dispatch path and
186/// the unknown-tool error case.
187pub(crate) async fn execute_tool(name: &str, args: &str, state: &AppState) -> Value {
188    let parsed: Value = serde_json::from_str(args).unwrap_or(json!({}));
189    match name {
190        "list_palaces" => execute_list_palaces(state).await,
191        "get_palace" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
192            Some(id) => execute_get_palace(state, id).await,
193            None => json!({ "error": "missing required argument: palace_id" }),
194        },
195        "recall_memories" => {
196            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
197            let q = parsed.get("query").and_then(|v| v.as_str());
198            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
199            match (pid, q) {
200                (Some(p), Some(q)) => execute_recall(state, p, q, top_k).await,
201                _ => json!({ "error": "missing required argument(s): palace_id, query" }),
202            }
203        }
204        "list_drawers" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
205            Some(id) => execute_list_drawers(state, id).await,
206            None => json!({ "error": "missing required argument: palace_id" }),
207        },
208        "kg_query" => {
209            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
210            let subj = parsed.get("subject").and_then(|v| v.as_str());
211            match (pid, subj) {
212                (Some(p), Some(s)) => execute_kg_query(state, p, s).await,
213                _ => json!({ "error": "missing required argument(s): palace_id, subject" }),
214            }
215        }
216        "get_config" => execute_get_config(state),
217        "get_status" => execute_get_status(state).await,
218        "get_dream_status" => execute_get_dream_status(state).await,
219        "get_palace_dream_status" => match parsed.get("palace_id").and_then(|v| v.as_str()) {
220            Some(id) => execute_get_palace_dream_status(state, id).await,
221            None => json!({ "error": "missing required argument: palace_id" }),
222        },
223        "create_memory" => {
224            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
225            let content = parsed.get("content").and_then(|v| v.as_str());
226            let room = parsed.get("room").and_then(|v| v.as_str());
227            let tags: Vec<String> = parsed
228                .get("tags")
229                .and_then(|v| v.as_array())
230                .map(|arr| {
231                    arr.iter()
232                        .filter_map(|t| t.as_str().map(|s| s.to_string()))
233                        .collect()
234                })
235                .unwrap_or_default();
236            let importance = parsed
237                .get("importance")
238                .and_then(|v| v.as_f64())
239                .map(|f| f as f32)
240                .unwrap_or(0.5);
241            match (pid, content) {
242                (Some(p), Some(c)) => {
243                    execute_create_memory(state, p, c, room, tags, importance).await
244                }
245                _ => json!({ "error": "missing required argument(s): palace_id, content" }),
246            }
247        }
248        "kg_assert" => {
249            let pid = parsed.get("palace_id").and_then(|v| v.as_str());
250            let subj = parsed.get("subject").and_then(|v| v.as_str());
251            let pred = parsed.get("predicate").and_then(|v| v.as_str());
252            let obj = parsed.get("object").and_then(|v| v.as_str());
253            let conf = parsed
254                .get("confidence")
255                .and_then(|v| v.as_f64())
256                .map(|f| f as f32)
257                .unwrap_or(1.0);
258            match (pid, subj, pred, obj) {
259                (Some(p), Some(s), Some(pr), Some(o)) => {
260                    execute_kg_assert(state, p, s, pr, o, conf).await
261                }
262                _ => json!({
263                    "error": "missing required argument(s): palace_id, subject, predicate, object"
264                }),
265            }
266        }
267        "memory_recall_all" => {
268            let q = parsed.get("q").and_then(|v| v.as_str());
269            let top_k = parsed.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
270            let deep = parsed
271                .get("deep")
272                .and_then(|v| v.as_bool())
273                .unwrap_or(false);
274            match q {
275                Some(q) => execute_recall_all(state, q, top_k, deep).await,
276                None => json!({ "error": "missing required argument: q" }),
277            }
278        }
279        _ => json!({ "error": format!("unknown tool: {name}") }),
280    }
281}
282
283/// Chat-surface twin of `MemoryService::list_palaces` (issue #4637).
284///
285/// Why: shares the fix, not just the shape — this path had the identical
286/// force-open-every-palace loop, so it was identically unusable at 5,794
287/// palaces. Rows for uncached palaces carry `cached: false` and zero counts.
288/// What: lists palaces on the blocking pool, enriches each row from the
289/// registry's open-handle cache via `peek`.
290/// Test: `list_palaces_does_not_open_uncached_palaces` covers the shared
291/// `palace_info_from` contract this relies on.
292async fn execute_list_palaces(state: &AppState) -> Value {
293    let palaces = match list_palaces_blocking(state).await {
294        Ok(v) => v,
295        Err(e) => return json!({ "error": format!("{e:#}") }),
296    };
297    let out: Vec<Value> = palaces
298        .into_iter()
299        .map(|p| {
300            // #4637: peek() not open_palace() — full-registry open is O(n) cold disk I/O
301            let handle = state.registry.peek(&p.id);
302            let info = palace_info_from(&p, handle.as_ref());
303            serde_json::to_value(info).unwrap_or(json!({}))
304        })
305        .collect();
306    json!(out)
307}
308
309async fn execute_get_palace(state: &AppState, id: &str) -> Value {
310    let palaces = match PalaceRegistry::list_palaces(&state.data_root) {
311        Ok(v) => v,
312        Err(e) => return json!({ "error": format!("list palaces: {e:#}") }),
313    };
314    match palaces.into_iter().find(|p| p.id.0 == id) {
315        Some(p) => {
316            let handle = state.registry.open_palace(&state.data_root, &p.id).ok();
317            serde_json::to_value(palace_info_from(&p, handle.as_ref())).unwrap_or(json!({}))
318        }
319        None => json!({ "error": format!("palace not found: {id}") }),
320    }
321}
322
323async fn execute_recall(state: &AppState, palace_id: &str, query: &str, top_k: usize) -> Value {
324    let handle = match state
325        .registry
326        .open_palace(&state.data_root, &PalaceId::new(palace_id))
327    {
328        Ok(h) => h,
329        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
330    };
331    match recall_with_default_embedder(&handle, query, top_k).await {
332        Ok(hits) => json!(hits
333            .into_iter()
334            .map(|r| json!({
335                "drawer_id": r.drawer.id.to_string(),
336                "content": r.drawer.content(),
337                "importance": r.drawer.importance,
338                "tags": r.drawer.tags,
339                "score": r.score,
340                "layer": r.layer,
341            }))
342            .collect::<Vec<_>>()),
343        Err(e) => json!({ "error": format!("recall: {e:#}") }),
344    }
345}
346
347/// Execute a cross-palace recall and return JSON results tagged with palace id.
348///
349/// Why: Both the MCP `memory_recall_all` tool and the `GET /api/v1/recall`
350/// HTTP route share the same wiring — list palaces, open handles, fan out via
351/// `recall_across_palaces_with_default_embedder`, and serialize.
352/// What: Lists every palace on disk, then streams them through
353/// `recall_streamed` in bounded batches, delegating each batch to the core
354/// fan-out. On success returns a JSON array; on listing failure returns
355/// `{ "error": "..." }`.
356/// Why (#7125): opening every palace at once made peak residency and the
357/// post-call LRU residue scale with the palace count. Batching bounds both;
358/// palaces the registry already held are left resident.
359/// Test: Indirectly via `recall_across_palaces_merges_results` (core merge
360/// logic) and the HTTP/MCP integration paths;
361/// `recall_all_returns_open_palaces_to_baseline` pins the residency bound.
362pub(crate) async fn execute_recall_all(
363    state: &AppState,
364    query: &str,
365    top_k: usize,
366    deep: bool,
367) -> Value {
368    let palaces = match list_palaces_blocking(state).await {
369        Ok(v) => v,
370        Err(e) => return json!({ "error": format!("{e:#}") }),
371    };
372    // #7125: stream the estate in batches instead of opening all of it.
373    let streamed = recall_streamed(
374        state,
375        &palaces,
376        "execute_recall_all",
377        top_k,
378        |handles| async move {
379            recall_across_palaces_with_default_embedder(&handles, query, top_k, deep).await
380        },
381    )
382    .await;
383    match streamed {
384        Ok(results) => json!(results
385            .into_iter()
386            .map(|r| json!({
387                "palace_id": r.palace_id,
388                "drawer_id": r.result.drawer.id.to_string(),
389                "content": r.result.drawer.content(),
390                "importance": r.result.drawer.importance,
391                "tags": r.result.drawer.tags,
392                "score": r.result.score,
393                "layer": r.result.layer,
394            }))
395            .collect::<Vec<_>>()),
396        Err(e) => json!({ "error": format!("recall_across_palaces: {e:#}") }),
397    }
398}
399
400async fn execute_list_drawers(state: &AppState, palace_id: &str) -> Value {
401    let handle = match state
402        .registry
403        .open_palace(&state.data_root, &PalaceId::new(palace_id))
404    {
405        Ok(h) => h,
406        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
407    };
408    let drawers = handle.list_drawers(None, None, 200);
409    serde_json::to_value(drawers).unwrap_or(json!([]))
410}
411
412async fn execute_kg_query(state: &AppState, palace_id: &str, subject: &str) -> Value {
413    let handle = match state
414        .registry
415        .open_palace(&state.data_root, &PalaceId::new(palace_id))
416    {
417        Ok(h) => h,
418        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
419    };
420    match handle.kg.query_active(subject).await {
421        Ok(triples) => serde_json::to_value(triples).unwrap_or(json!([])),
422        Err(e) => json!({ "error": format!("kg query: {e:#}") }),
423    }
424}
425
426fn execute_get_config(state: &AppState) -> Value {
427    let cfg = load_user_config().unwrap_or_default();
428    json!({
429        "openrouter_configured": !cfg.openrouter_api_key.is_empty(),
430        "openrouter_model": cfg.openrouter_model,
431        "local_model": {
432            "enabled": cfg.local_model.enabled,
433            "base_url": cfg.local_model.base_url,
434            "model": cfg.local_model.model,
435        },
436        "data_root": state.data_root.display().to_string(),
437    })
438}
439
440/// Chat-surface twin of `MemoryService::status` (issue #4637).
441///
442/// Why: this had a byte-identical inline copy of the `collect_palace_stats`
443/// loop — including the force-open-every-palace bug. It now delegates to the
444/// shared helper so the fix (and any future one) lands once, and it reports
445/// `cached_palace_count` so the changed meaning of the totals is on the wire
446/// here too.
447/// What: lists palaces on the blocking pool, sums counts across the
448/// cache-resident subset via `collect_palace_stats`.
449/// Test: `status_does_not_open_uncached_palaces` covers the shared helper.
450async fn execute_get_status(state: &AppState) -> Value {
451    let palaces = list_palaces_blocking(state).await.unwrap_or_default();
452    // #4637: peek() not open_palace() — full-registry open is O(n) cold disk I/O
453    let stats = collect_palace_stats(state, palaces.iter().map(|p| &p.id));
454    json!({
455        "version": state.version,
456        "palace_count": palaces.len(),
457        "default_palace": state.default_palace,
458        "data_root": state.data_root.display().to_string(),
459        "total_drawers": stats.total_drawers,
460        "total_vectors": stats.total_vectors,
461        "total_kg_triples": stats.total_kg_triples,
462        "cached_palace_count": stats.cached_palace_count,
463    })
464}
465
466pub(crate) async fn execute_get_dream_status(state: &AppState) -> Value {
467    let palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
468    let mut out = DreamStatusPayload::default();
469    let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
470    for p in palaces {
471        let data_dir = state.data_root.join(p.id.as_str());
472        let snap = match PersistedDreamStats::load(&data_dir) {
473            Ok(Some(s)) => s,
474            _ => continue,
475        };
476        out.merged = out.merged.saturating_add(snap.stats.merged);
477        out.pruned = out.pruned.saturating_add(snap.stats.pruned);
478        out.compacted = out.compacted.saturating_add(snap.stats.compacted);
479        out.closets_updated = out
480            .closets_updated
481            .saturating_add(snap.stats.closets_updated);
482        out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
483        latest = match latest {
484            Some(t) if t >= snap.last_run_at => Some(t),
485            _ => Some(snap.last_run_at),
486        };
487    }
488    out.last_run_at = latest;
489    serde_json::to_value(out).unwrap_or(json!({}))
490}
491
492async fn execute_get_palace_dream_status(state: &AppState, palace_id: &str) -> Value {
493    let data_dir = state.data_root.join(palace_id);
494    if !data_dir.exists() {
495        return json!({ "error": format!("palace not found: {palace_id}") });
496    }
497    match PersistedDreamStats::load(&data_dir) {
498        Ok(Some(s)) => serde_json::to_value(DreamStatusPayload::from(s)).unwrap_or(json!({})),
499        Ok(None) => serde_json::to_value(DreamStatusPayload::default()).unwrap_or(json!({})),
500        Err(e) => json!({ "error": format!("read dream stats: {e:#}") }),
501    }
502}
503
504async fn execute_create_memory(
505    state: &AppState,
506    palace_id: &str,
507    content: &str,
508    room: Option<&str>,
509    tags: Vec<String>,
510    importance: f32,
511) -> Value {
512    let handle = match state
513        .registry
514        .open_palace(&state.data_root, &PalaceId::new(palace_id))
515    {
516        Ok(h) => h,
517        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
518    };
519    let room = room.map(RoomType::parse).unwrap_or(RoomType::General);
520    match handle
521        .remember(content.to_string(), room, tags, importance)
522        .await
523    {
524        Ok(id) => json!({ "drawer_id": id.to_string(), "status": "stored" }),
525        Err(e) => json!({ "error": format!("remember: {e:#}") }),
526    }
527}
528
529/// Assert a KG triple on behalf of the chat assistant.
530///
531/// #4888: `predicate` and `object` come straight from the model's tool call,
532/// so this can write any of the four hot predicates at any length. Without the
533/// admission gate an ordinary chat turn could push the always-injected surface
534/// past 20 facts, or land an unbounded one — defeating both limits through the
535/// path users hit most routinely. The refusal is returned as `{"error": …}`
536/// like every other failure here, so the model sees it and can retire a fact.
537///
538/// #4905: it also owed the prompt-cache rebuild and never ran it, so a user who
539/// told the assistant to remember a standing rule got a success report and a
540/// rule that reached no later turn. Routing through
541/// [`crate::kg_write::assert_triple`] makes that structural rather than
542/// remembered.
543/// Test: `chat_kg_assert_refreshes_prompt_cache`,
544/// `chat_kg_assert_reports_tier_s_refusal_without_writing` in
545/// `web::tests::chat_tests`.
546async fn execute_kg_assert(
547    state: &AppState,
548    palace_id: &str,
549    subject: &str,
550    predicate: &str,
551    object: &str,
552    confidence: f32,
553) -> Value {
554    let handle = match state
555        .registry
556        .open_palace(&state.data_root, &PalaceId::new(palace_id))
557    {
558        Ok(h) => h,
559        Err(e) => return json!({ "error": format!("open palace {palace_id}: {e:#}") }),
560    };
561    let triple = Triple {
562        subject: subject.to_string(),
563        predicate: predicate.to_string(),
564        object: object.to_string(),
565        valid_from: chrono::Utc::now(),
566        valid_to: None,
567        confidence,
568        provenance: Some("chat:assistant".to_string()),
569    };
570    // #4905: the shared entry point owns admission, the write, and the
571    // prompt-cache refresh, so a fact written from chat reaches later turns.
572    match crate::kg_write::assert_triple(state, &handle, triple, CachePolicy::Inline).await {
573        Ok(_) => json!({ "status": "asserted" }),
574        Err(e) => json!({ "error": format!("{e:#}") }),
575    }
576}