Skip to main content

lash_lashlang_runtime/
catalogue_preview.rs

1//! Catalogue-preview prompt contribution for RLM deferred tool discovery.
2//!
3//! Resident catalog members render as full RLM tool docs. A host may also keep
4//! a larger searchable catalogue outside the resident catalog and resolve
5//! selected Lashlang call paths on demand. This formatter advertises that
6//! searchable tail as a compact module index plus the instruction to use
7//! `tools.search(...)` and then call the returned module path directly.
8
9use std::collections::BTreeMap;
10use std::fmt::Write as _;
11
12use lash_core::{PromptContribution, ToolManifest};
13use serde_json::Value;
14
15use crate::{LASHLANG_TOOL_BINDING_KEY, LashlangToolBinding, ResolvedLashlangToolBinding};
16
17pub const DEFAULT_CATALOGUE_PREVIEW_MODULE_LIMIT: usize = 100;
18pub const DEFAULT_CATALOGUE_PREVIEW_CALL_NAME_LIMIT: usize = 50;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct CataloguePreviewEntry {
22    pub module_path: Vec<String>,
23    pub call: String,
24}
25
26impl CataloguePreviewEntry {
27    pub fn new(
28        module_path: impl IntoIterator<Item = impl Into<String>>,
29        call: impl Into<String>,
30    ) -> Self {
31        Self {
32            module_path: module_path.into_iter().map(Into::into).collect(),
33            call: call.into(),
34        }
35    }
36
37    pub fn from_lashlang_executable(executable: ResolvedLashlangToolBinding) -> Self {
38        let call = executable.call_path();
39        Self {
40            module_path: executable.module_path,
41            call,
42        }
43    }
44
45    pub fn module_path_string(&self) -> String {
46        self.module_path.join(".")
47    }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct CataloguePreviewOptions {
52    pub title: String,
53    pub search_tool_name: String,
54    pub search_call_path: String,
55    pub module_limit: usize,
56    pub call_name_limit: usize,
57}
58
59impl Default for CataloguePreviewOptions {
60    fn default() -> Self {
61        Self {
62            title: "Catalogued Capabilities".to_string(),
63            search_tool_name: "search_tools".to_string(),
64            search_call_path: "tools.search".to_string(),
65            module_limit: DEFAULT_CATALOGUE_PREVIEW_MODULE_LIMIT,
66            call_name_limit: DEFAULT_CATALOGUE_PREVIEW_CALL_NAME_LIMIT,
67        }
68    }
69}
70
71/// Build a catalogue-preview contribution from the projected JSON catalogue
72/// consumed by a `search_tools` implementation.
73///
74/// Each record needs a `name` and a `bindings["lashlang.tool"]` value. Extra
75/// fields such as id, description, and compact contract are ignored by the
76/// preview but can still be used by the search index.
77pub fn catalogue_preview_contribution(catalog: &[Value]) -> Option<PromptContribution> {
78    catalogue_preview_contribution_for_entries(catalogue_preview_entries_from_catalog_records(
79        catalog,
80    ))
81}
82
83pub fn catalogue_preview_contribution_with_options(
84    catalog: &[Value],
85    options: CataloguePreviewOptions,
86) -> Option<PromptContribution> {
87    catalogue_preview_contribution_for_entries_with_options(
88        catalogue_preview_entries_from_catalog_records(catalog),
89        options,
90    )
91}
92
93pub fn catalogue_preview_contribution_for_manifests<'a>(
94    manifests: impl IntoIterator<Item = &'a ToolManifest>,
95) -> Option<PromptContribution> {
96    catalogue_preview_contribution_for_entries(catalogue_preview_entries_from_manifests(manifests))
97}
98
99pub fn catalogue_preview_contribution_for_entries(
100    entries: impl IntoIterator<Item = CataloguePreviewEntry>,
101) -> Option<PromptContribution> {
102    catalogue_preview_contribution_for_entries_with_options(
103        entries,
104        CataloguePreviewOptions::default(),
105    )
106}
107
108pub fn catalogue_preview_contribution_for_entries_with_options(
109    entries: impl IntoIterator<Item = CataloguePreviewEntry>,
110    options: CataloguePreviewOptions,
111) -> Option<PromptContribution> {
112    let mut by_module: BTreeMap<String, Vec<String>> = BTreeMap::new();
113    let mut catalogued_count = 0usize;
114    for entry in entries {
115        catalogued_count += 1;
116        by_module
117            .entry(entry.module_path_string())
118            .or_default()
119            .push(entry.call);
120    }
121    if catalogued_count == 0 {
122        return None;
123    }
124    for names in by_module.values_mut() {
125        names.sort_unstable();
126    }
127
128    let search_call = options.search_call_path.trim().to_string();
129    let search_tool_name = options.search_tool_name.trim().to_string();
130    let mut rendered = format!(
131        "The capabilities below are callable directly by their module path; the listing is usually enough to call them. \
132         Use `{search_call}(...)` only if you need more detail than shown, or to find a capability not listed here — \
133         `await {search_call}({{ query: \"...\" }})?`, then call the returned module path. \
134         Results use the same compact contract shape as resident capabilities: call path, signature, description, and capped examples."
135    );
136
137    if by_module.len() <= options.module_limit {
138        rendered.push_str("\n\nModules: ");
139        for (index, (module, names)) in by_module.iter().enumerate() {
140            if index > 0 {
141                rendered.push_str(", ");
142            }
143            let _ = write!(rendered, "{module}({})", names.len());
144        }
145    } else {
146        let _ = write!(
147            rendered,
148            "\n\nModules: {} total; use `{search_call}` to narrow them.",
149            by_module.len()
150        );
151    }
152
153    if catalogued_count <= options.call_name_limit {
154        rendered.push_str("\n\nCatalogued calls:");
155        for (module, names) in by_module {
156            rendered.push('\n');
157            let _ = write!(rendered, "{module}: {}", names.join(", "));
158        }
159    }
160
161    let contribution = PromptContribution::execution(options.title, rendered);
162    if search_tool_name.is_empty() {
163        Some(contribution)
164    } else {
165        Some(contribution.requires_tool(search_tool_name))
166    }
167}
168
169pub fn catalogue_preview_entries_from_catalog_records(
170    catalog: &[Value],
171) -> Vec<CataloguePreviewEntry> {
172    catalog
173        .iter()
174        .filter_map(catalogue_preview_entry_from_catalog_record)
175        .collect()
176}
177
178pub fn catalogue_preview_entries_from_manifests<'a>(
179    manifests: impl IntoIterator<Item = &'a ToolManifest>,
180) -> Vec<CataloguePreviewEntry> {
181    manifests
182        .into_iter()
183        .filter_map(catalogue_preview_entry_from_manifest)
184        .collect()
185}
186
187pub fn catalogue_preview_entry_from_manifest(
188    manifest: &ToolManifest,
189) -> Option<CataloguePreviewEntry> {
190    let binding = manifest
191        .bindings
192        .get(LASHLANG_TOOL_BINDING_KEY)
193        .cloned()
194        .and_then(|value| serde_json::from_value::<LashlangToolBinding>(value).ok())?;
195    let executable = binding.executable_for(&manifest.name).ok()?;
196    Some(CataloguePreviewEntry::from_lashlang_executable(executable))
197}
198
199pub fn catalogue_preview_entry_from_catalog_record(raw: &Value) -> Option<CataloguePreviewEntry> {
200    let obj = raw.as_object()?;
201    let name = obj.get("name")?.as_str()?;
202    let binding: LashlangToolBinding = obj
203        .get("bindings")
204        .and_then(|bindings| bindings.get(LASHLANG_TOOL_BINDING_KEY))
205        .cloned()
206        .and_then(|value| serde_json::from_value(value).ok())?;
207    let executable = binding.executable_for(name).ok()?;
208    Some(CataloguePreviewEntry::from_lashlang_executable(executable))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::ToolDefinitionLashlangExt;
215    use serde_json::json;
216
217    fn catalog_record(name: &str, module_path: &[&str], operation: &str) -> Value {
218        let definition = lash_core::ToolDefinition::raw(
219            format!("tool:{name}"),
220            name,
221            "Test tool",
222            lash_core::ToolDefinition::default_input_schema(),
223            json!({ "type": "object" }),
224        )
225        .with_lashlang_binding(LashlangToolBinding::new(
226            module_path.iter().copied(),
227            operation,
228        ));
229        let manifest = definition.manifest();
230        json!({
231            "id": manifest.id,
232            "name": manifest.name,
233            "bindings": manifest.bindings,
234            "contract": manifest.compact_contract,
235        })
236    }
237
238    #[test]
239    fn catalogue_preview_contribution_groups_catalog_records_by_module() {
240        let catalog = vec![
241            catalog_record("gmail_fetch_email", &["gmail"], "fetch_email"),
242            catalog_record("figments_list", &["figments"], "list"),
243        ];
244
245        let contribution =
246            catalogue_preview_contribution(&catalog).expect("catalogue preview contribution");
247
248        assert_eq!(
249            contribution.title.as_deref(),
250            Some("Catalogued Capabilities")
251        );
252        assert_eq!(contribution.gate.tools, vec!["search_tools".to_string()]);
253        assert!(
254            contribution
255                .content
256                .contains("callable directly by their module path")
257        );
258        assert!(
259            contribution
260                .content
261                .contains("only if you need more detail than shown")
262        );
263        assert!(
264            contribution
265                .content
266                .contains("Modules: figments(1), gmail(1)")
267        );
268        assert!(contribution.content.contains("figments: figments.list"));
269        assert!(contribution.content.contains("gmail: gmail.fetch_email"));
270    }
271
272    #[test]
273    fn catalogue_preview_contribution_can_render_from_manifests() {
274        let definition = lash_core::ToolDefinition::raw(
275            "tool:calendar_work_create",
276            "calendar_work_create",
277            "Create a work calendar event",
278            lash_core::ToolDefinition::default_input_schema(),
279            json!({ "type": "object" }),
280        )
281        .with_lashlang_binding(LashlangToolBinding::new(["calendar", "work"], "create"));
282        let manifest = definition.manifest();
283
284        let contribution = catalogue_preview_contribution_for_manifests([&manifest])
285            .expect("catalogue preview contribution");
286
287        assert!(contribution.content.contains("calendar.work(1)"));
288        assert!(
289            contribution
290                .content
291                .contains("calendar.work: calendar.work.create")
292        );
293    }
294
295    #[test]
296    fn catalogue_preview_options_customize_search_tool_and_limits() {
297        let entries = vec![
298            CataloguePreviewEntry::new(["one"], "one.call"),
299            CataloguePreviewEntry::new(["two"], "two.call"),
300        ];
301        let contribution = catalogue_preview_contribution_for_entries_with_options(
302            entries,
303            CataloguePreviewOptions {
304                title: "Hidden Tools".to_string(),
305                search_tool_name: "find_tools".to_string(),
306                search_call_path: "tools.find".to_string(),
307                module_limit: 1,
308                call_name_limit: 1,
309            },
310        )
311        .expect("catalogue preview contribution");
312
313        assert_eq!(contribution.title.as_deref(), Some("Hidden Tools"));
314        assert_eq!(contribution.gate.tools, vec!["find_tools".to_string()]);
315        assert!(
316            contribution
317                .content
318                .contains("Modules: 2 total; use `tools.find` to narrow them.")
319        );
320        assert!(!contribution.content.contains("Catalogued calls:"));
321    }
322}