Skip to main content

lash_lashlang_runtime/
deferred.rs

1//! RLM-only deferred tool resolution.
2//!
3//! A host-provided [`DeferredToolResolver`] resolves Lashlang call-paths absent
4//! from the link-time [`LashlangHostEnvironment`] into [`ToolGrant`] values
5//! (which carry their Tool Execution Bindings) or reports `NotAvailable`. The
6//! resolver resolves on demand only — it does not enumerate, advertise, or rank
7//! tools.
8//!
9//! Linking runs a `gather → resolve → link` pass around the synchronous
10//! [`lashlang::LinkedModule::link`]: collect the call-paths the program
11//! references but the host environment does not provide, resolve the unknowns,
12//! fold `Resolved` grants into the host environment, then link. Each resolution
13//! is recorded so a re-driven link reuses it without calling the resolver
14//! again, and the flat Tool Catalog is never mutated — resolution is
15//! link-scoped only.
16
17use std::collections::BTreeMap;
18use std::sync::Arc;
19
20use async_trait::async_trait;
21
22use crate::{LashlangHostEnvironment, required_tool_lashlang_executable};
23
24/// A host-authorized tool capability resolved for a deferred call-path. It
25/// carries the callable contract and Lashlang identity (via the tool
26/// definition) plus the host-owned Tool Execution Binding that routes a call to
27/// the backing account, service, secret, or remote executor.
28#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
29pub struct ToolGrant {
30    /// The callable contract and Lashlang identity for the resolved tool.
31    pub definition: lash_core::ToolDefinition,
32    /// Optional registry source route authorized by the host. Registry-backed
33    /// grants require this route at execution time; direct host providers may
34    /// ignore it.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub source_id: Option<String>,
37    /// Host-owned routing authority that connects the grant to the backing
38    /// account/service/secret/executor. Opaque to the runtime; the host
39    /// interprets it when fulfilling the call and when rebuilding for replay.
40    #[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/// Outcome of resolving one deferred call-path.
65#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum Resolution {
68    /// The call-path resolved to a host-authorized tool.
69    Resolved(Box<ToolGrant>),
70    /// No tool is available for the call-path; linking leaves the symbol
71    /// unresolved so the model sees a clean link error.
72    NotAvailable,
73}
74
75/// RLM-only, host-provided resolution of Lashlang call-paths absent from the
76/// link-time host environment. The resolver resolves on demand only.
77#[async_trait]
78pub trait DeferredToolResolver: Send + Sync {
79    /// Resolve a deterministic batch of fully-qualified Lashlang call-paths
80    /// (e.g. `web.fetch`). The batch contains only paths not already provided
81    /// by the host environment or recorded for this link.
82    ///
83    /// Resolution is non-transactional: every returned path has its own
84    /// outcome, partial success is normal, and an input path omitted from the
85    /// returned map is recorded as [`Resolution::NotAvailable`]. Entries for
86    /// paths outside the input batch are ignored.
87    async fn resolve(&self, paths: &[&str]) -> BTreeMap<String, Resolution>;
88
89    /// Reinstall the process-local execution route for a recorded grant before
90    /// it is folded into a re-driven link. This is replay rehydration only: it
91    /// must not make an authorization decision or widen the recorded grant.
92    ///
93    /// Hosts whose grants need no process-local routing can use this default
94    /// no-op implementation.
95    fn install_recorded_grant(&self, _path: &str, _grant: &ToolGrant) {}
96}
97
98/// A handle to the host's deferred resolver, optional because most hosts ship
99/// no deferral.
100pub type SharedDeferredToolResolver = Arc<dyn DeferredToolResolver>;
101
102/// Stable identity of one `ExecCode` link. The scope distinguishes logical
103/// turns and protocol iterations, while the effect and replay keys distinguish
104/// individual code effects and their durable re-drives.
105#[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/// A per-link record of every deferred resolution, keyed by call-path within
136/// the execution scope. Replay/recovery applies the record so the resolver is
137/// never called twice for the same link. Captures both `Resolved` grants (with
138/// their Tool Execution Binding) and negative `NotAvailable` results.
139#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
140pub struct DeferredResolutionRecord {
141    /// The code link whose outcomes are stored in `resolutions`. `None` is the
142    /// inactive state before an executor selects its first link.
143    #[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    /// Select the active code link, retaining outcomes only when the stable
150    /// identity matches. A new link replaces the entire record so authority and
151    /// negative availability results cannot leak across code effects.
152    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    /// Clear the active link when execution has no stable `ExecCode` identity.
160    /// Such an invocation cannot safely reuse durable resolution outcomes.
161    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
179/// Fold a resolved [`ToolGrant`] into the host environment so the subsequent
180/// link can bind its call-path. The flat catalog is untouched.
181fn 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
197/// Whether the host environment already binds `call_path` (dotted
198/// `module.operation`), so it does not need deferral.
199fn 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
208/// `gather → resolve`: collect every module call-path `program` references,
209/// resolve the ones `host_environment` does not already provide in one
210/// record-filtered batch, record the per-path outcomes, and fold `Resolved`
211/// grants into `host_environment`. Returns the augmented host environment; the
212/// caller links (or compiles via a cache) against it.
213///
214/// Every resolution is written to `record` so a re-driven or recovered link
215/// replays the recorded outcomes without calling the resolver again. The flat
216/// Tool Catalog is never mutated — resolution is link-scoped only.
217pub 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        // Replay: a recorded outcome wins and is never re-authorized. A
244        // recorded grant is reinstalled into any process-local host route
245        // before it is folded back into the link environment.
246        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            // No resolver: leave the symbol unresolved for a clean link error.
259            continue;
260        };
261        if let Resolution::Resolved(grant) = resolution {
262            // A corrupt grant is treated as a clean unresolved link.
263            let _ = fold_grant(&mut host_environment, &grant);
264        }
265    }
266
267    host_environment
268}
269
270/// `gather → resolve → link`: [`resolve_and_fold_deferred`] then link. Used by
271/// callers that do not maintain their own compile cache. `NotAvailable` (and no
272/// resolver) leaves the symbol unresolved, surfacing a clean model-visible link
273/// error.
274pub 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        // Re-drive the same link with the recorded resolutions: the resolver is
416        // never called again.
417        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        // Replay reuses the recorded NotAvailable without re-resolving.
460        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        // Every referenced path now has a recorded outcome, so the filtered
502        // unknown bag is empty and no second batch is sent. Only the recorded
503        // positive grant is replay-installed.
504        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}