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 "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}