1use 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
71pub 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}