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        "Catalogued capabilities: {catalogued_count} capabilities are searchable through `{search_call}(...)`.\n\
132         When a task needs a capability not documented here, run `await {search_call}({{ query: \"...\" }})?` and call the returned module path directly. \
133         Results use the same compact contract shape as resident capabilities: call path, signature, description, and capped examples."
134    );
135
136    if by_module.len() <= options.module_limit {
137        rendered.push_str("\n\nModules: ");
138        for (index, (module, names)) in by_module.iter().enumerate() {
139            if index > 0 {
140                rendered.push_str(", ");
141            }
142            let _ = write!(rendered, "{module}({})", names.len());
143        }
144    } else {
145        let _ = write!(
146            rendered,
147            "\n\nModules: {} total; use `{search_call}` to narrow them.",
148            by_module.len()
149        );
150    }
151
152    if catalogued_count <= options.call_name_limit {
153        rendered.push_str("\n\nCatalogued calls:");
154        for (module, names) in by_module {
155            rendered.push('\n');
156            let _ = write!(rendered, "{module}: {}", names.join(", "));
157        }
158    }
159
160    let contribution = PromptContribution::execution(options.title, rendered);
161    if search_tool_name.is_empty() {
162        Some(contribution)
163    } else {
164        Some(contribution.requires_tool(search_tool_name))
165    }
166}
167
168pub fn catalogue_preview_entries_from_catalog_records(
169    catalog: &[Value],
170) -> Vec<CataloguePreviewEntry> {
171    catalog
172        .iter()
173        .filter_map(catalogue_preview_entry_from_catalog_record)
174        .collect()
175}
176
177pub fn catalogue_preview_entries_from_manifests<'a>(
178    manifests: impl IntoIterator<Item = &'a ToolManifest>,
179) -> Vec<CataloguePreviewEntry> {
180    manifests
181        .into_iter()
182        .filter_map(catalogue_preview_entry_from_manifest)
183        .collect()
184}
185
186pub fn catalogue_preview_entry_from_manifest(
187    manifest: &ToolManifest,
188) -> Option<CataloguePreviewEntry> {
189    let binding = manifest
190        .bindings
191        .get(LASHLANG_TOOL_BINDING_KEY)
192        .cloned()
193        .and_then(|value| serde_json::from_value::<LashlangToolBinding>(value).ok())?;
194    let executable = binding.executable_for(&manifest.name).ok()?;
195    Some(CataloguePreviewEntry::from_lashlang_executable(executable))
196}
197
198pub fn catalogue_preview_entry_from_catalog_record(raw: &Value) -> Option<CataloguePreviewEntry> {
199    let obj = raw.as_object()?;
200    let name = obj.get("name")?.as_str()?;
201    let binding: LashlangToolBinding = obj
202        .get("bindings")
203        .and_then(|bindings| bindings.get(LASHLANG_TOOL_BINDING_KEY))
204        .cloned()
205        .and_then(|value| serde_json::from_value(value).ok())?;
206    let executable = binding.executable_for(name).ok()?;
207    Some(CataloguePreviewEntry::from_lashlang_executable(executable))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::ToolDefinitionLashlangExt;
214    use serde_json::json;
215
216    fn catalog_record(name: &str, module_path: &[&str], operation: &str) -> Value {
217        let definition = lash_core::ToolDefinition::raw(
218            format!("tool:{name}"),
219            name,
220            "Test tool",
221            lash_core::ToolDefinition::default_input_schema(),
222            json!({ "type": "object" }),
223        )
224        .with_lashlang_binding(LashlangToolBinding::new(
225            module_path.iter().copied(),
226            operation,
227        ));
228        let manifest = definition.manifest();
229        json!({
230            "id": manifest.id,
231            "name": manifest.name,
232            "bindings": manifest.bindings,
233            "contract": manifest.compact_contract,
234        })
235    }
236
237    #[test]
238    fn catalogue_preview_contribution_groups_catalog_records_by_module() {
239        let catalog = vec![
240            catalog_record("gmail_fetch_email", &["gmail"], "fetch_email"),
241            catalog_record("figments_list", &["figments"], "list"),
242        ];
243
244        let contribution =
245            catalogue_preview_contribution(&catalog).expect("catalogue preview contribution");
246
247        assert_eq!(
248            contribution.title.as_deref(),
249            Some("Catalogued Capabilities")
250        );
251        assert_eq!(contribution.gate.tools, vec!["search_tools".to_string()]);
252        assert!(
253            contribution
254                .content
255                .contains("Catalogued capabilities: 2 capabilities")
256        );
257        assert!(
258            contribution
259                .content
260                .contains("Modules: figments(1), gmail(1)")
261        );
262        assert!(contribution.content.contains("figments: figments.list"));
263        assert!(contribution.content.contains("gmail: gmail.fetch_email"));
264    }
265
266    #[test]
267    fn catalogue_preview_contribution_can_render_from_manifests() {
268        let definition = lash_core::ToolDefinition::raw(
269            "tool:calendar_work_create",
270            "calendar_work_create",
271            "Create a work calendar event",
272            lash_core::ToolDefinition::default_input_schema(),
273            json!({ "type": "object" }),
274        )
275        .with_lashlang_binding(LashlangToolBinding::new(["calendar", "work"], "create"));
276        let manifest = definition.manifest();
277
278        let contribution = catalogue_preview_contribution_for_manifests([&manifest])
279            .expect("catalogue preview contribution");
280
281        assert!(contribution.content.contains("calendar.work(1)"));
282        assert!(
283            contribution
284                .content
285                .contains("calendar.work: calendar.work.create")
286        );
287    }
288
289    #[test]
290    fn catalogue_preview_options_customize_search_tool_and_limits() {
291        let entries = vec![
292            CataloguePreviewEntry::new(["one"], "one.call"),
293            CataloguePreviewEntry::new(["two"], "two.call"),
294        ];
295        let contribution = catalogue_preview_contribution_for_entries_with_options(
296            entries,
297            CataloguePreviewOptions {
298                title: "Hidden Tools".to_string(),
299                search_tool_name: "find_tools".to_string(),
300                search_call_path: "tools.find".to_string(),
301                module_limit: 1,
302                call_name_limit: 1,
303            },
304        )
305        .expect("catalogue preview contribution");
306
307        assert_eq!(contribution.title.as_deref(), Some("Hidden Tools"));
308        assert_eq!(contribution.gate.tools, vec!["find_tools".to_string()]);
309        assert!(
310            contribution
311                .content
312                .contains("Modules: 2 total; use `tools.find` to narrow them.")
313        );
314        assert!(!contribution.content.contains("Catalogued calls:"));
315    }
316}