Skip to main content

lash_lashlang_runtime/
deferred.rs

1//! RLM-only deferred tool resolution.
2//!
3//! A host-provided [`DeferredToolResolver`] resolves a Lashlang call-path that
4//! is absent from the link-time [`LashlangHostEnvironment`] into a [`ToolGrant`]
5//! (which carries its Tool Execution Binding) 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 turn 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 a Lashlang call-path 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 fully-qualified Lashlang call-path (e.g. `web.fetch`) into a
80    /// [`ToolGrant`] or report `NotAvailable`.
81    async fn resolve(&self, path: &str) -> Resolution;
82}
83
84/// A handle to the host's deferred resolver, optional because most hosts ship
85/// no deferral.
86pub type SharedDeferredToolResolver = Arc<dyn DeferredToolResolver>;
87
88/// A per-link record of every deferred resolution, keyed by call-path within
89/// the execution scope. Replay/recovery applies the record so the resolver is
90/// never called twice for the same link. Captures both `Resolved` grants (with
91/// their Tool Execution Binding) and negative `NotAvailable` results.
92#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
93pub struct DeferredResolutionRecord {
94    pub resolutions: BTreeMap<String, Resolution>,
95}
96
97impl DeferredResolutionRecord {
98    pub fn get(&self, path: &str) -> Option<&Resolution> {
99        self.resolutions.get(path)
100    }
101
102    pub fn record(&mut self, path: impl Into<String>, resolution: Resolution) {
103        self.resolutions.insert(path.into(), resolution);
104    }
105
106    pub fn is_empty(&self) -> bool {
107        self.resolutions.is_empty()
108    }
109}
110
111/// Fold a resolved [`ToolGrant`] into the host environment so the subsequent
112/// link can bind its call-path. The flat catalog is untouched.
113fn fold_grant(
114    host_environment: &mut LashlangHostEnvironment,
115    grant: &ToolGrant,
116) -> Result<(), String> {
117    let binding = required_tool_lashlang_executable(&grant.definition.manifest)?;
118    host_environment.resources.add_module_operation(
119        binding.module_path.iter().map(String::as_str),
120        binding.authority_type.clone(),
121        binding.operation.clone(),
122        grant.definition.manifest.id.to_string(),
123        lashlang::TypeExpr::Any,
124        lashlang::TypeExpr::Any,
125    );
126    Ok(())
127}
128
129/// Whether the host environment already binds `call_path` (dotted
130/// `module.operation`), so it does not need deferral.
131fn already_provided(host_environment: &LashlangHostEnvironment, call_path: &str) -> bool {
132    let Some((module_path, operation)) = call_path.rsplit_once('.') else {
133        return false;
134    };
135    host_environment
136        .resources
137        .provides_module_operation(module_path, operation)
138}
139
140/// `gather → resolve`: collect every module call-path `program` references,
141/// resolve the ones `host_environment` does not already provide (without
142/// failing fast on the first), record the outcomes, and fold `Resolved` grants
143/// into `host_environment`. Returns the augmented host environment; the caller
144/// links (or compiles via a cache) against it.
145///
146/// Every resolution is written to `record` so a re-driven or recovered turn
147/// replays the recorded outcomes without calling the resolver again. The flat
148/// Tool Catalog is never mutated — resolution is link-scoped only.
149pub async fn resolve_and_fold_deferred(
150    program: &lashlang::Program,
151    mut host_environment: LashlangHostEnvironment,
152    resolver: Option<&SharedDeferredToolResolver>,
153    record: &mut DeferredResolutionRecord,
154) -> LashlangHostEnvironment {
155    let referenced = lashlang::referenced_module_call_paths(program);
156    let unresolved = referenced
157        .into_iter()
158        .filter(|path| !already_provided(&host_environment, path))
159        .collect::<Vec<_>>();
160
161    for path in unresolved {
162        // Replay: a recorded outcome wins and is never re-resolved.
163        let resolution = if let Some(recorded) = record.get(&path) {
164            recorded.clone()
165        } else if let Some(resolver) = resolver {
166            let resolution = resolver.resolve(&path).await;
167            record.record(path.clone(), resolution.clone());
168            resolution
169        } else {
170            // No resolver: leave the symbol unresolved for a clean link error.
171            continue;
172        };
173        if let Resolution::Resolved(grant) = resolution {
174            // A corrupt grant is treated as a clean unresolved link.
175            let _ = fold_grant(&mut host_environment, &grant);
176        }
177    }
178
179    host_environment
180}
181
182/// `gather → resolve → link`: [`resolve_and_fold_deferred`] then link. Used by
183/// callers that do not maintain their own compile cache. `NotAvailable` (and no
184/// resolver) leaves the symbol unresolved, surfacing a clean model-visible link
185/// error.
186pub async fn link_with_deferred_resolution(
187    program: lashlang::Program,
188    host_environment: LashlangHostEnvironment,
189    resolver: Option<&SharedDeferredToolResolver>,
190    record: &mut DeferredResolutionRecord,
191) -> Result<lashlang::LinkedModule, lashlang::LinkError> {
192    let host_environment =
193        resolve_and_fold_deferred(&program, host_environment, resolver, record).await;
194    lashlang::LinkedModule::link(program, host_environment)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::{LashlangSurface, LashlangToolBinding, ToolDefinitionLashlangExt};
201    use std::sync::atomic::{AtomicUsize, Ordering};
202
203    fn grant(name: &str, module: &str, operation: &str) -> ToolGrant {
204        let definition = lash_core::ToolDefinition::raw(
205            format!("tool:{name}"),
206            name,
207            format!("Tool {name}"),
208            lash_core::ToolDefinition::default_input_schema(),
209            serde_json::json!({ "type": "string" }),
210        )
211        .with_lashlang_binding(LashlangToolBinding::new([module], operation));
212        ToolGrant::new(definition).with_execution_binding(serde_json::json!({ "account": name }))
213    }
214
215    struct CountingResolver {
216        grant: ToolGrant,
217        calls: Arc<AtomicUsize>,
218    }
219
220    #[async_trait]
221    impl DeferredToolResolver for CountingResolver {
222        async fn resolve(&self, path: &str) -> Resolution {
223            self.calls.fetch_add(1, Ordering::SeqCst);
224            if path == "web.fetch" {
225                Resolution::Resolved(Box::new(self.grant.clone()))
226            } else {
227                Resolution::NotAvailable
228            }
229        }
230    }
231
232    fn empty_host_environment() -> LashlangHostEnvironment {
233        let catalog = lash_core::ToolCatalog::default();
234        LashlangSurface::default()
235            .host_environment(&catalog)
236            .expect("empty host environment")
237    }
238
239    #[tokio::test]
240    async fn resolves_deferred_call_path_and_records_grant() {
241        let calls = Arc::new(AtomicUsize::new(0));
242        let resolver: SharedDeferredToolResolver = Arc::new(CountingResolver {
243            grant: grant("fetch_url", "web", "fetch"),
244            calls: Arc::clone(&calls),
245        });
246        let program = lashlang::parse(r#"await web.fetch({ url: "x" })?"#).expect("parse");
247        let mut record = DeferredResolutionRecord::default();
248
249        link_with_deferred_resolution(
250            program,
251            empty_host_environment(),
252            Some(&resolver),
253            &mut record,
254        )
255        .await
256        .expect("deferred resolution links");
257
258        assert_eq!(calls.load(Ordering::SeqCst), 1);
259        assert!(matches!(
260            record.get("web.fetch"),
261            Some(Resolution::Resolved(_))
262        ));
263    }
264
265    #[tokio::test]
266    async fn replay_reuses_record_without_calling_resolver() {
267        let calls = Arc::new(AtomicUsize::new(0));
268        let resolver: SharedDeferredToolResolver = Arc::new(CountingResolver {
269            grant: grant("fetch_url", "web", "fetch"),
270            calls: Arc::clone(&calls),
271        });
272        let program = lashlang::parse(r#"await web.fetch({ url: "x" })?"#).expect("parse");
273
274        let mut record = DeferredResolutionRecord::default();
275        link_with_deferred_resolution(
276            program.clone(),
277            empty_host_environment(),
278            Some(&resolver),
279            &mut record,
280        )
281        .await
282        .expect("first link");
283        assert_eq!(calls.load(Ordering::SeqCst), 1);
284
285        // Re-drive the same link with the recorded resolutions: the resolver is
286        // never called again.
287        link_with_deferred_resolution(
288            program,
289            empty_host_environment(),
290            Some(&resolver),
291            &mut record,
292        )
293        .await
294        .expect("replayed link");
295        assert_eq!(
296            calls.load(Ordering::SeqCst),
297            1,
298            "replay must not re-resolve"
299        );
300    }
301
302    #[tokio::test]
303    async fn not_available_surfaces_clean_link_error_and_is_recorded() {
304        let calls = Arc::new(AtomicUsize::new(0));
305        let resolver: SharedDeferredToolResolver = Arc::new(CountingResolver {
306            grant: grant("fetch_url", "web", "fetch"),
307            calls: Arc::clone(&calls),
308        });
309        let program = lashlang::parse(r#"await mystery.run({})?"#).expect("parse");
310        let mut record = DeferredResolutionRecord::default();
311
312        let err = link_with_deferred_resolution(
313            program.clone(),
314            empty_host_environment(),
315            Some(&resolver),
316            &mut record,
317        )
318        .await
319        .expect_err("unavailable call-path must surface a link error");
320        assert!(!format!("{err:?}").is_empty());
321        assert!(matches!(
322            record.get("mystery.run"),
323            Some(Resolution::NotAvailable)
324        ));
325
326        // Replay reuses the recorded NotAvailable without re-resolving.
327        let calls_before = calls.load(Ordering::SeqCst);
328        link_with_deferred_resolution(
329            program,
330            empty_host_environment(),
331            Some(&resolver),
332            &mut record,
333        )
334        .await
335        .expect_err("replayed unavailable call-path still errors");
336        assert_eq!(calls.load(Ordering::SeqCst), calls_before);
337    }
338}