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