1use std::collections::BTreeMap;
18use std::sync::Arc;
19
20use async_trait::async_trait;
21
22use crate::{LashlangHostEnvironment, required_tool_lashlang_executable};
23
24#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
29pub struct ToolGrant {
30 pub definition: lash_core::ToolDefinition,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub source_id: Option<String>,
37 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
41 pub execution_binding: serde_json::Value,
42}
43
44impl ToolGrant {
45 pub fn new(definition: lash_core::ToolDefinition) -> Self {
46 Self {
47 definition,
48 source_id: None,
49 execution_binding: serde_json::Value::Null,
50 }
51 }
52
53 pub fn with_source_id(mut self, source_id: impl Into<String>) -> Self {
54 self.source_id = Some(source_id.into());
55 self
56 }
57
58 pub fn with_execution_binding(mut self, execution_binding: serde_json::Value) -> Self {
59 self.execution_binding = execution_binding;
60 self
61 }
62}
63
64#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum Resolution {
68 Resolved(Box<ToolGrant>),
70 NotAvailable,
73}
74
75#[async_trait]
78pub trait DeferredToolResolver: Send + Sync {
79 async fn resolve(&self, paths: &[&str]) -> BTreeMap<String, Resolution>;
88
89 fn install_recorded_grant(&self, _path: &str, _grant: &ToolGrant) {}
96}
97
98pub type SharedDeferredToolResolver = Arc<dyn DeferredToolResolver>;
101
102#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106pub struct DeferredResolutionLinkKey {
107 pub session_id: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub turn_id: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub turn_index: Option<usize>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub protocol_iteration: Option<usize>,
114 pub effect_id: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub replay_key: Option<String>,
117}
118
119impl DeferredResolutionLinkKey {
120 pub fn from_exec_code_invocation(invocation: &lash_core::RuntimeInvocation) -> Option<Self> {
121 if invocation.effect_kind() != Some(lash_core::RuntimeEffectKind::ExecCode) {
122 return None;
123 }
124 Some(Self {
125 session_id: invocation.scope.session_id.clone(),
126 turn_id: invocation.scope.turn_id.clone(),
127 turn_index: invocation.scope.turn_index,
128 protocol_iteration: invocation.scope.protocol_iteration,
129 effect_id: invocation.effect_id()?.to_string(),
130 replay_key: invocation.replay_key().map(str::to_string),
131 })
132 }
133}
134
135#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
140pub struct DeferredResolutionRecord {
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub link_key: Option<DeferredResolutionLinkKey>,
145 pub resolutions: BTreeMap<String, Resolution>,
146}
147
148impl DeferredResolutionRecord {
149 pub fn select_link(&mut self, link_key: DeferredResolutionLinkKey) {
153 if self.link_key.as_ref() != Some(&link_key) {
154 self.link_key = Some(link_key);
155 self.resolutions.clear();
156 }
157 }
158
159 pub fn clear_link(&mut self) {
162 self.link_key = None;
163 self.resolutions.clear();
164 }
165
166 pub fn get(&self, path: &str) -> Option<&Resolution> {
167 self.resolutions.get(path)
168 }
169
170 pub fn record(&mut self, path: impl Into<String>, resolution: Resolution) {
171 self.resolutions.insert(path.into(), resolution);
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.resolutions.is_empty()
176 }
177}
178
179fn fold_grant(
182 host_environment: &mut LashlangHostEnvironment,
183 grant: &ToolGrant,
184) -> Result<(), String> {
185 let binding = required_tool_lashlang_executable(&grant.definition.manifest)?;
186 host_environment.resources.add_module_operation(
187 binding.module_path.iter().map(String::as_str),
188 binding.authority_type.clone(),
189 binding.operation.clone(),
190 grant.definition.manifest.id.to_string(),
191 lashlang::TypeExpr::Any,
192 lashlang::TypeExpr::Any,
193 );
194 Ok(())
195}
196
197fn already_provided(host_environment: &LashlangHostEnvironment, call_path: &str) -> bool {
200 let Some((module_path, operation)) = call_path.rsplit_once('.') else {
201 return false;
202 };
203 host_environment
204 .resources
205 .provides_module_operation(module_path, operation)
206}
207
208pub async fn resolve_and_fold_deferred(
218 program: &lashlang::Program,
219 mut host_environment: LashlangHostEnvironment,
220 resolver: Option<&SharedDeferredToolResolver>,
221 record: &mut DeferredResolutionRecord,
222) -> LashlangHostEnvironment {
223 let referenced = lashlang::referenced_module_call_paths(program);
224 let unresolved = referenced
225 .into_iter()
226 .filter(|path| !already_provided(&host_environment, path))
227 .collect::<Vec<_>>();
228
229 let unknown = unresolved
230 .iter()
231 .filter(|path| record.get(path).is_none())
232 .map(String::as_str)
233 .collect::<Vec<_>>();
234 let mut resolved = if let Some(resolver) = resolver
235 && !unknown.is_empty()
236 {
237 resolver.resolve(&unknown).await
238 } else {
239 BTreeMap::new()
240 };
241
242 for path in unresolved {
243 let resolution = if let Some(recorded) = record.get(&path) {
247 if let Resolution::Resolved(grant) = recorded
248 && let Some(resolver) = resolver
249 {
250 resolver.install_recorded_grant(&path, grant);
251 }
252 recorded.clone()
253 } else if resolver.is_some() {
254 let resolution = resolved.remove(&path).unwrap_or(Resolution::NotAvailable);
255 record.record(path.clone(), resolution.clone());
256 resolution
257 } else {
258 continue;
260 };
261 if let Resolution::Resolved(grant) = resolution {
262 let _ = fold_grant(&mut host_environment, &grant);
264 }
265 }
266
267 host_environment
268}
269
270pub async fn link_with_deferred_resolution(
275 program: lashlang::Program,
276 host_environment: LashlangHostEnvironment,
277 resolver: Option<&SharedDeferredToolResolver>,
278 record: &mut DeferredResolutionRecord,
279) -> Result<lashlang::LinkedModule, lashlang::LinkError> {
280 let host_environment =
281 resolve_and_fold_deferred(&program, host_environment, resolver, record).await;
282 lashlang::LinkedModule::link(program, host_environment)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::{LashlangSurface, LashlangToolBinding, ToolDefinitionLashlangExt};
289 use std::sync::Mutex;
290 use std::sync::atomic::{AtomicUsize, Ordering};
291
292 fn grant(name: &str, module: &str, operation: &str) -> ToolGrant {
293 let definition = lash_core::ToolDefinition::raw(
294 format!("tool:{name}"),
295 name,
296 format!("Tool {name}"),
297 lash_core::ToolDefinition::default_input_schema(),
298 serde_json::json!({ "type": "string" }),
299 )
300 .with_lashlang_binding(LashlangToolBinding::new([module], operation));
301 ToolGrant::new(definition).with_execution_binding(serde_json::json!({ "account": name }))
302 }
303
304 struct CountingResolver {
305 grant: ToolGrant,
306 calls: Arc<AtomicUsize>,
307 batches: Arc<Mutex<Vec<Vec<String>>>>,
308 installed: Arc<Mutex<Vec<(String, ToolGrant)>>>,
309 }
310
311 #[async_trait]
312 impl DeferredToolResolver for CountingResolver {
313 async fn resolve(&self, paths: &[&str]) -> BTreeMap<String, Resolution> {
314 self.calls.fetch_add(1, Ordering::SeqCst);
315 self.batches
316 .lock()
317 .expect("batches")
318 .push(paths.iter().map(|path| (*path).to_string()).collect());
319 paths
320 .iter()
321 .filter(|path| **path == "web.fetch")
322 .map(|path| {
323 (
324 (*path).to_string(),
325 Resolution::Resolved(Box::new(self.grant.clone())),
326 )
327 })
328 .collect()
329 }
330
331 fn install_recorded_grant(&self, path: &str, grant: &ToolGrant) {
332 self.installed
333 .lock()
334 .expect("installed grants")
335 .push((path.to_string(), grant.clone()));
336 }
337 }
338
339 struct ResolverHarness {
340 resolver: SharedDeferredToolResolver,
341 calls: Arc<AtomicUsize>,
342 batches: Arc<Mutex<Vec<Vec<String>>>>,
343 installed: Arc<Mutex<Vec<(String, ToolGrant)>>>,
344 }
345
346 fn resolver_harness() -> ResolverHarness {
347 let calls = Arc::new(AtomicUsize::new(0));
348 let batches = Arc::new(Mutex::new(Vec::new()));
349 let installed = Arc::new(Mutex::new(Vec::new()));
350 let resolver = Arc::new(CountingResolver {
351 grant: grant("fetch_url", "web", "fetch"),
352 calls: Arc::clone(&calls),
353 batches: Arc::clone(&batches),
354 installed: Arc::clone(&installed),
355 });
356 ResolverHarness {
357 resolver,
358 calls,
359 batches,
360 installed,
361 }
362 }
363
364 fn empty_host_environment() -> LashlangHostEnvironment {
365 let catalog = lash_core::ToolCatalog::default();
366 LashlangSurface::default()
367 .host_environment(&catalog)
368 .expect("empty host environment")
369 }
370
371 #[tokio::test]
372 async fn resolves_deferred_call_path_and_records_grant() {
373 let harness = resolver_harness();
374 let program = lashlang::parse(r#"await web.fetch({ url: "x" })?"#).expect("parse");
375 let mut record = DeferredResolutionRecord::default();
376
377 link_with_deferred_resolution(
378 program,
379 empty_host_environment(),
380 Some(&harness.resolver),
381 &mut record,
382 )
383 .await
384 .expect("deferred resolution links");
385
386 assert_eq!(harness.calls.load(Ordering::SeqCst), 1);
387 assert_eq!(
388 *harness.batches.lock().expect("batches"),
389 vec![vec!["web.fetch".to_string()]]
390 );
391 assert!(harness.installed.lock().expect("installed").is_empty());
392 assert!(matches!(
393 record.get("web.fetch"),
394 Some(Resolution::Resolved(_))
395 ));
396 }
397
398 #[tokio::test]
399 async fn replay_reuses_record_without_calling_resolver() {
400 let harness = resolver_harness();
401 let program = lashlang::parse(r#"await web.fetch({ url: "x" })?"#).expect("parse");
402
403 let mut record = DeferredResolutionRecord::default();
404 link_with_deferred_resolution(
405 program.clone(),
406 empty_host_environment(),
407 Some(&harness.resolver),
408 &mut record,
409 )
410 .await
411 .expect("first link");
412 assert_eq!(harness.calls.load(Ordering::SeqCst), 1);
413 assert!(harness.installed.lock().expect("installed").is_empty());
414
415 link_with_deferred_resolution(
418 program,
419 empty_host_environment(),
420 Some(&harness.resolver),
421 &mut record,
422 )
423 .await
424 .expect("replayed link");
425 assert_eq!(
426 harness.calls.load(Ordering::SeqCst),
427 1,
428 "replay must not re-resolve"
429 );
430 let installed = harness.installed.lock().expect("installed");
431 assert_eq!(installed.len(), 1);
432 assert_eq!(installed[0].0, "web.fetch");
433 assert_eq!(
434 installed[0].1.execution_binding,
435 serde_json::json!({ "account": "fetch_url" })
436 );
437 }
438
439 #[tokio::test]
440 async fn not_available_surfaces_clean_link_error_and_is_recorded() {
441 let harness = resolver_harness();
442 let program = lashlang::parse(r#"await mystery.run({})?"#).expect("parse");
443 let mut record = DeferredResolutionRecord::default();
444
445 let err = link_with_deferred_resolution(
446 program.clone(),
447 empty_host_environment(),
448 Some(&harness.resolver),
449 &mut record,
450 )
451 .await
452 .expect_err("unavailable call-path must surface a link error");
453 assert!(!format!("{err:?}").is_empty());
454 assert!(matches!(
455 record.get("mystery.run"),
456 Some(Resolution::NotAvailable)
457 ));
458
459 let calls_before = harness.calls.load(Ordering::SeqCst);
461 link_with_deferred_resolution(
462 program,
463 empty_host_environment(),
464 Some(&harness.resolver),
465 &mut record,
466 )
467 .await
468 .expect_err("replayed unavailable call-path still errors");
469 assert_eq!(harness.calls.load(Ordering::SeqCst), calls_before);
470 assert!(harness.installed.lock().expect("installed").is_empty());
471 }
472
473 #[tokio::test]
474 async fn resolves_unknown_paths_in_one_record_filtered_batch() {
475 let harness = resolver_harness();
476 let program =
477 lashlang::parse("await web.fetch({})?\nawait mystery.run({})?\nawait web.fetch({})?")
478 .expect("parse");
479 let mut record = DeferredResolutionRecord::default();
480
481 let host = resolve_and_fold_deferred(
482 &program,
483 empty_host_environment(),
484 Some(&harness.resolver),
485 &mut record,
486 )
487 .await;
488
489 assert_eq!(harness.calls.load(Ordering::SeqCst), 1);
490 assert_eq!(
491 *harness.batches.lock().expect("batches"),
492 vec![vec!["mystery.run".to_string(), "web.fetch".to_string()]]
493 );
494 assert!(host.resources.provides_module_operation("web", "fetch"));
495 assert!(!host.resources.provides_module_operation("mystery", "run"));
496 assert!(matches!(
497 record.get("mystery.run"),
498 Some(Resolution::NotAvailable)
499 ));
500
501 let replayed = resolve_and_fold_deferred(
505 &program,
506 empty_host_environment(),
507 Some(&harness.resolver),
508 &mut record,
509 )
510 .await;
511 assert_eq!(harness.calls.load(Ordering::SeqCst), 1);
512 assert!(replayed.resources.provides_module_operation("web", "fetch"));
513 assert_eq!(harness.installed.lock().expect("installed").len(), 1);
514 }
515
516 #[tokio::test]
517 async fn excludes_recorded_paths_from_a_non_empty_batch() {
518 let harness = resolver_harness();
519 let program =
520 lashlang::parse("await web.fetch({})?\nawait mystery.run({})?").expect("parse");
521 let mut record = DeferredResolutionRecord::default();
522 record.record(
523 "web.fetch",
524 Resolution::Resolved(Box::new(grant("fetch_url", "web", "fetch"))),
525 );
526
527 let host = resolve_and_fold_deferred(
528 &program,
529 empty_host_environment(),
530 Some(&harness.resolver),
531 &mut record,
532 )
533 .await;
534
535 assert_eq!(harness.calls.load(Ordering::SeqCst), 1);
536 assert_eq!(
537 *harness.batches.lock().expect("batches"),
538 vec![vec!["mystery.run".to_string()]]
539 );
540 assert_eq!(harness.installed.lock().expect("installed").len(), 1);
541 assert!(host.resources.provides_module_operation("web", "fetch"));
542 assert!(matches!(
543 record.get("mystery.run"),
544 Some(Resolution::NotAvailable)
545 ));
546 }
547}