Skip to main content

slop_ai/
tools.rs

1//! LLM tool integration — convert SLOP affordances into LLM-consumable tool
2//! definitions and format trees for context injection.
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::types::SlopNode;
10
11/// An LLM tool definition (OpenAI-compatible format).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LlmTool {
14    #[serde(rename = "type")]
15    pub tool_type: String,
16    pub function: LlmFunction,
17}
18
19/// The function part of an LLM tool.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LlmFunction {
22    pub name: String,
23    pub description: String,
24    pub parameters: Value,
25}
26
27/// Resolved path and action for a tool name.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ToolResolution {
30    pub path: String,
31    pub action: String,
32}
33
34/// A set of LLM tools with a resolver to map short names back to full paths.
35#[derive(Debug, Clone)]
36pub struct ToolSet {
37    pub tools: Vec<LlmTool>,
38    resolve_map: HashMap<String, ToolResolution>,
39}
40
41impl ToolSet {
42    /// Resolve a tool name back to its path and action for invoke messages.
43    pub fn resolve(&self, tool_name: &str) -> Option<&ToolResolution> {
44        self.resolve_map.get(tool_name)
45    }
46}
47
48fn sanitize(s: &str) -> String {
49    s.chars()
50        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
51        .collect()
52}
53
54struct Entry {
55    short_name: String,
56    path: String,
57    action: String,
58    ancestors: Vec<String>,
59    label: Option<String>,
60    description: Option<String>,
61    dangerous: bool,
62    params: Option<Value>,
63}
64
65/// Walk a node tree and collect all affordances as LLM tools.
66///
67/// Tool names use short `{nodeId}__{action}` format. Collisions are
68/// disambiguated by prepending parent IDs.
69pub fn affordances_to_tools(node: &SlopNode, path: &str) -> ToolSet {
70    let mut entries = Vec::new();
71    collect(node, path, &[], &mut entries);
72
73    let name_map = disambiguate(&entries);
74
75    let mut tools = Vec::new();
76    let mut resolve_map = HashMap::new();
77
78    for (i, entry) in entries.iter().enumerate() {
79        let tool_name = &name_map[i];
80        let p = if entry.path.is_empty() { "/" } else { &entry.path };
81
82        resolve_map.insert(
83            tool_name.clone(),
84            ToolResolution { path: p.to_string(), action: entry.action.clone() },
85        );
86
87        let label = entry.label.as_deref().unwrap_or(&entry.action);
88        let mut desc = match &entry.description {
89            Some(d) => format!("{label}: {d}"),
90            None => label.to_string(),
91        };
92        desc.push_str(&format!(" (on {p})"));
93        if entry.dangerous {
94            desc.push_str(" [DANGEROUS - confirm first]");
95        }
96
97        let parameters = entry.params.clone()
98            .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
99
100        tools.push(LlmTool {
101            tool_type: "function".into(),
102            function: LlmFunction {
103                name: tool_name.clone(),
104                description: desc,
105                parameters,
106            },
107        });
108    }
109
110    ToolSet { tools, resolve_map }
111}
112
113fn collect(node: &SlopNode, path: &str, ancestors: &[String], out: &mut Vec<Entry>) {
114    let safe_id = sanitize(&node.id);
115    if let Some(affs) = &node.affordances {
116        for aff in affs {
117            let safe_action = sanitize(&aff.action);
118            let p = if path.is_empty() { "/".to_string() } else { path.to_string() };
119            out.push(Entry {
120                short_name: format!("{safe_id}__{safe_action}"),
121                path: p,
122                action: aff.action.clone(),
123                ancestors: ancestors.iter().map(|a| sanitize(a)).collect(),
124                label: aff.label.clone(),
125                description: aff.description.clone(),
126                dangerous: aff.dangerous,
127                params: aff.params.clone(),
128            });
129        }
130    }
131    if let Some(children) = &node.children {
132        let mut new_ancestors = ancestors.to_vec();
133        new_ancestors.push(node.id.clone());
134        for child in children {
135            let child_path = format!("{}/{}", path, child.id);
136            collect(child, &child_path, &new_ancestors, out);
137        }
138    }
139}
140
141fn disambiguate(entries: &[Entry]) -> Vec<String> {
142    let mut result = vec![String::new(); entries.len()];
143
144    // Group by short name
145    let mut groups: HashMap<&str, Vec<usize>> = HashMap::new();
146    for (i, e) in entries.iter().enumerate() {
147        groups.entry(&e.short_name).or_default().push(i);
148    }
149
150    for (short_name, indices) in &groups {
151        if indices.len() == 1 {
152            result[indices[0]] = short_name.to_string();
153            continue;
154        }
155        // Collision — prepend ancestors until unique
156        for &idx in indices {
157            let entry = &entries[idx];
158            let mut name = short_name.to_string();
159            for i in (0..entry.ancestors.len()).rev() {
160                name = format!("{}__{name}", entry.ancestors[i]);
161                let mut unique = true;
162                let depth = entry.ancestors.len() - 1 - i;
163                for &other in indices {
164                    if other == idx { continue; }
165                    let oe = &entries[other];
166                    let mut o_name = short_name.to_string();
167                    for j in (0..oe.ancestors.len()).rev().take(depth + 1) {
168                        o_name = format!("{}__{o_name}", oe.ancestors[j]);
169                    }
170                    if o_name == name {
171                        unique = false;
172                        break;
173                    }
174                }
175                if unique { break; }
176            }
177            result[idx] = name;
178        }
179    }
180
181    result
182}
183
184/// Format a node tree as an indented text block suitable for LLM context.
185pub fn format_tree(node: &SlopNode, indent: usize) -> String {
186    let mut out = String::new();
187    write_node(node, indent, &mut out);
188    out
189}
190
191fn write_node(node: &SlopNode, indent: usize, out: &mut String) {
192    let pad = "  ".repeat(indent);
193
194    // Header: [type] nodeId: label
195    let display_name = node.properties.as_ref().and_then(|p| {
196        p.get("label")
197            .or_else(|| p.get("title"))
198            .and_then(|v| v.as_str())
199    });
200    let header = match display_name {
201        Some(name) if name != node.id => format!("{}: {}", node.id, name),
202        _ => node.id.clone(),
203    };
204    out.push_str(&format!("{pad}[{}] {header}", node.node_type));
205
206    // Extra properties (skip label and title)
207    if let Some(props) = &node.properties {
208        let pairs: Vec<String> = props
209            .iter()
210            .filter(|(k, _)| k.as_str() != "label" && k.as_str() != "title")
211            .map(|(k, v)| format!("{k}={v}"))
212            .collect();
213        if !pairs.is_empty() {
214            out.push_str(&format!(" ({})", pairs.join(", ")));
215        }
216    }
217
218    // Meta: flags, summary, salience
219    if let Some(meta) = &node.meta {
220        let mut flags = Vec::new();
221        if meta.pinned == Some(true) {
222            flags.push("pinned");
223        }
224        if meta.focus == Some(true) {
225            flags.push("focus");
226        }
227        if meta.changed == Some(true) {
228            flags.push("changed");
229        }
230        if let Some(ref u) = meta.urgency {
231            flags.push(match u {
232                crate::types::Urgency::Critical => "CRITICAL",
233                crate::types::Urgency::High => "HIGH",
234                crate::types::Urgency::Medium => "medium",
235                crate::types::Urgency::Low => "low",
236                crate::types::Urgency::None => "",
237            });
238        }
239        let flags: Vec<&str> = flags.into_iter().filter(|f| !f.is_empty()).collect();
240        if !flags.is_empty() {
241            out.push_str(&format!(" [{}]", flags.join(", ")));
242        }
243        if let Some(ref summary) = meta.summary {
244            out.push_str(&format!("  \u{2014} \"{summary}\""));
245        }
246        if let Some(salience) = meta.salience {
247            out.push_str(&format!("  salience={}", (salience * 100.0).round() / 100.0));
248        }
249    }
250
251    // Affordances inline
252    if let Some(affs) = &node.affordances {
253        if !affs.is_empty() {
254            let acts: Vec<String> = affs
255                .iter()
256                .map(|aff| {
257                    let mut s = aff.action.clone();
258                    if let Some(ref params) = aff.params {
259                        if let Some(props) = params.get("properties").and_then(|p| p.as_object())
260                        {
261                            let param_strs: Vec<String> = props
262                                .iter()
263                                .map(|(k, v)| {
264                                    let typ =
265                                        v.get("type").and_then(|t| t.as_str()).unwrap_or("?");
266                                    format!("{k}: {typ}")
267                                })
268                                .collect();
269                            if !param_strs.is_empty() {
270                                s.push_str(&format!("({})", param_strs.join(", ")));
271                            }
272                        }
273                    }
274                    s
275                })
276                .collect();
277            out.push_str(&format!("  actions: {{{}}}", acts.join(", ")));
278        }
279    }
280
281    out.push('\n');
282
283    // Windowing indicators
284    if let Some(meta) = &node.meta {
285        let child_count = node.children.as_ref().map_or(0, |c| c.len());
286        if let Some(total) = meta.total_children {
287            if total > child_count {
288                if meta.window.is_some() {
289                    out.push_str(&format!(
290                        "{pad}  (showing {} of {})\n",
291                        child_count, total
292                    ));
293                } else if child_count == 0 {
294                    let noun = if total == 1 { "child" } else { "children" };
295                    out.push_str(&format!("{pad}  ({} {} not loaded)\n", total, noun));
296                }
297            }
298        }
299    }
300
301    if let Some(children) = &node.children {
302        for child in children {
303            write_node(child, indent + 1, out);
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::types::{NodeMeta, SlopNode, Urgency};
312    use serde_json::json;
313
314    fn sample_tree() -> SlopNode {
315        serde_json::from_value(json!({
316            "id": "app",
317            "type": "root",
318            "properties": {"label": "My App"},
319            "children": [
320                {
321                    "id": "counter",
322                    "type": "status",
323                    "properties": {"count": 5},
324                    "affordances": [
325                        {"action": "increment", "label": "Add one", "description": "Increment the counter"},
326                        {"action": "reset", "dangerous": true}
327                    ]
328                }
329            ]
330        }))
331        .unwrap()
332    }
333
334    /// Canonical test tree matching spec/core/state-tree.md "Consumer display format".
335    fn canonical_tree() -> SlopNode {
336        serde_json::from_value(json!({
337            "id": "store",
338            "type": "root",
339            "properties": {"label": "Pet Store"},
340            "meta": {"salience": 0.9},
341            "affordances": [
342                {"action": "search", "params": {"type": "object", "properties": {"query": {"type": "string"}}}}
343            ],
344            "children": [
345                {
346                    "id": "catalog",
347                    "type": "collection",
348                    "properties": {"label": "Catalog", "count": 142},
349                    "meta": {"total_children": 142, "window": [0, 25], "summary": "142 products, 12 on sale"},
350                    "children": [
351                        {
352                            "id": "prod-1",
353                            "type": "item",
354                            "properties": {"label": "Rubber Duck", "price": 4.99, "in_stock": true},
355                            "affordances": [
356                                {"action": "add_to_cart", "params": {"type": "object", "properties": {"quantity": {"type": "number"}}}},
357                                {"action": "view"}
358                            ]
359                        }
360                    ]
361                },
362                {
363                    "id": "cart",
364                    "type": "collection",
365                    "properties": {"label": "Cart"},
366                    "meta": {"total_children": 3, "summary": "3 items, $24.97"}
367                }
368            ]
369        }))
370        .unwrap()
371    }
372
373    #[test]
374    fn test_short_tool_names() {
375        let tree = sample_tree();
376        let ts = affordances_to_tools(&tree, "/app");
377        assert_eq!(ts.tools.len(), 2);
378        assert_eq!(ts.tools[0].tool_type, "function");
379        assert_eq!(ts.tools[0].function.name, "counter__increment");
380        assert_eq!(ts.tools[1].function.name, "counter__reset");
381    }
382
383    #[test]
384    fn test_resolve() {
385        let tree = sample_tree();
386        let ts = affordances_to_tools(&tree, "/app");
387        let r = ts.resolve("counter__increment").unwrap();
388        assert_eq!(r.path, "/app/counter");
389        assert_eq!(r.action, "increment");
390    }
391
392    #[test]
393    fn test_disambiguate_collisions() {
394        let tree: SlopNode = serde_json::from_value(json!({
395            "id": "root", "type": "root",
396            "children": [
397                { "id": "board-1", "type": "view", "children": [
398                    { "id": "backlog", "type": "collection", "affordances": [{"action": "reorder"}] }
399                ]},
400                { "id": "board-2", "type": "view", "children": [
401                    { "id": "backlog", "type": "collection", "affordances": [{"action": "reorder"}] }
402                ]}
403            ]
404        })).unwrap();
405        let ts = affordances_to_tools(&tree, "");
406        assert_eq!(ts.tools.len(), 2);
407        let names: Vec<&str> = ts.tools.iter().map(|t| t.function.name.as_str()).collect();
408        assert!(names.contains(&"board_1__backlog__reorder"));
409        assert!(names.contains(&"board_2__backlog__reorder"));
410
411        let r1 = ts.resolve("board_1__backlog__reorder").unwrap();
412        assert_eq!(r1.path, "/board-1/backlog");
413        let r2 = ts.resolve("board_2__backlog__reorder").unwrap();
414        assert_eq!(r2.path, "/board-2/backlog");
415    }
416
417    #[test]
418    fn test_format_tree_header_id_and_label() {
419        let text = format_tree(&canonical_tree(), 0);
420        assert!(text.contains("[root] store: Pet Store"), "missing root header:\n{text}");
421        assert!(text.contains("[collection] catalog: Catalog"), "missing catalog header:\n{text}");
422        assert!(text.contains("[item] prod-1: Rubber Duck"), "missing prod header:\n{text}");
423    }
424
425    #[test]
426    fn test_format_tree_header_id_only_when_no_label() {
427        let node = SlopNode::new("status", "status");
428        let text = format_tree(&node, 0);
429        assert!(text.contains("[status] status"), "missing id-only header:\n{text}");
430    }
431
432    #[test]
433    fn test_format_tree_extra_props_exclude_label() {
434        let text = format_tree(&canonical_tree(), 0);
435        assert!(text.contains("count=142"), "missing count prop:\n{text}");
436        assert!(!text.contains("label="), "label= should be excluded:\n{text}");
437    }
438
439    #[test]
440    fn test_format_tree_meta_summary_quoted() {
441        let text = format_tree(&canonical_tree(), 0);
442        assert!(text.contains("\"142 products, 12 on sale\""), "missing catalog summary:\n{text}");
443        assert!(text.contains("\"3 items, $24.97\""), "missing cart summary:\n{text}");
444    }
445
446    #[test]
447    fn test_format_tree_meta_salience() {
448        let text = format_tree(&canonical_tree(), 0);
449        assert!(text.contains("salience=0.9"), "missing salience:\n{text}");
450    }
451
452    #[test]
453    fn test_format_tree_affordances_inline_with_params() {
454        let text = format_tree(&canonical_tree(), 0);
455        assert!(text.contains("actions: {search(query: string)}"), "missing search:\n{text}");
456        assert!(text.contains("add_to_cart(quantity: number)"), "missing add_to_cart:\n{text}");
457        assert!(text.contains("view}"), "missing view:\n{text}");
458    }
459
460    #[test]
461    fn test_format_tree_windowed_collection() {
462        let text = format_tree(&canonical_tree(), 0);
463        assert!(text.contains("(showing 1 of 142)"), "missing windowed indicator:\n{text}");
464    }
465
466    #[test]
467    fn test_format_tree_lazy_collection() {
468        let text = format_tree(&canonical_tree(), 0);
469        assert!(text.contains("(3 children not loaded)"), "missing lazy indicator:\n{text}");
470    }
471
472    #[test]
473    fn test_format_tree_with_meta_flags() {
474        let mut tree = sample_tree();
475        tree.meta = Some(NodeMeta {
476            summary: Some("Root node".into()),
477            focus: Some(true),
478            urgency: Some(Urgency::High),
479            ..NodeMeta::default()
480        });
481        let text = format_tree(&tree, 0);
482        assert!(text.contains("[focus, HIGH]"), "missing flags:\n{text}");
483        assert!(text.contains("\"Root node\""), "missing summary:\n{text}");
484    }
485
486    #[test]
487    fn test_format_tree_indentation() {
488        let text = format_tree(&canonical_tree(), 0);
489        let lines: Vec<&str> = text.lines().collect();
490        assert!(lines[0].starts_with("[root]"), "root should be at indent 0");
491        let catalog = lines.iter().find(|l| l.contains("catalog")).unwrap();
492        assert!(catalog.starts_with("  [collection]"), "catalog should be at indent 1");
493        let prod = lines.iter().find(|l| l.contains("prod-1")).unwrap();
494        assert!(prod.starts_with("    [item]"), "prod-1 should be at indent 2");
495    }
496
497    #[test]
498    fn test_no_affordances() {
499        let tree = SlopNode::new("empty", "group");
500        let ts = affordances_to_tools(&tree, "/empty");
501        assert!(ts.tools.is_empty());
502    }
503}