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