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