Skip to main content

greentic_aw_runtime/
component_source.rs

1//! Per-tenant agentic-worker component tool catalog.
2//!
3//! Exposes greentic `.gtpack` **components** (the canonical Component layer)
4//! to an agentic worker as LLM tools, mirroring [`crate::mcp_source`] for the
5//! MCP surface. A worker's `AgentConfig.tools` entry of the form
6//! `ToolRef { extension_id: "component:<component_ref>", tool_name: "<operation>" }`
7//! resolves here: the catalog supplies the LLM-facing `description`/`parameters`
8//! for the list seam and routes the call to a [`ComponentInvoker`] for dispatch.
9//!
10//! The actual WASM component instantiation lives in the runner host (over its
11//! `PackRuntime` component host), behind the [`ComponentInvoker`] trait, so
12//! this crate stays free of any `wasmtime`/runner-host dependency — it sees
13//! only the trait and JSON.
14//!
15//! Resilience contract (a component tool must never break an agent step):
16//! - Building a catalog is infallible: a [`ComponentInvoker`] that surfaces no
17//!   operations simply yields an empty catalog. [`ComponentToolSource::catalog`]
18//!   never returns or propagates an error.
19//! - [`ComponentToolCatalog::dispatch`] always returns a JSON
20//!   [`serde_json::Value`] and never panics — an unknown `(component_ref,
21//!   operation)` or an invoker failure becomes `{"error": "..."}` so the LLM
22//!   observes it as a normal tool result.
23//!
24//! Like the MCP source (and unlike the designer's `mcp__server__tool` string
25//! mangling), every tool is keyed by a `(component_ref, operation)` tuple.
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31use std::time::{Duration, Instant};
32
33use dashmap::DashMap;
34use serde_json::json;
35
36use crate::tenant::TenantContext;
37
38/// How long a built catalog is reused before a rebuild is considered.
39const CATALOG_TTL: Duration = Duration::from_secs(5 * 60);
40
41/// LLM-facing schema for one component operation: enough to build an
42/// `LlmToolSchema` in [`crate::tools::list_tools_for_llm`].
43#[derive(Clone, Debug)]
44pub struct ComponentToolEntry {
45    pub description: String,
46    pub parameters: serde_json::Value,
47}
48
49/// One component operation discoverable as an agentic-worker tool: the
50/// `(component_ref, operation)` identity plus its LLM-facing schema. Produced
51/// by [`ComponentInvoker::list_operations`].
52#[derive(Clone, Debug)]
53pub struct ComponentOperation {
54    pub component_ref: String,
55    pub operation: String,
56    pub description: String,
57    pub parameters: serde_json::Value,
58}
59
60/// Host-side seam that resolves and invokes greentic components. The concrete
61/// implementation lives in the runner host (backed by `PackRuntime`); this
62/// crate depends only on the trait + JSON so it need not pull in
63/// `wasmtime`/runner-host.
64///
65/// Both methods are total: `list_operations` returns whatever is currently
66/// exposed (possibly empty), and `invoke` reports failure via `Err(String)`
67/// which [`ComponentToolCatalog::dispatch`] wraps into an `{"error": ...}`
68/// value — neither aborts an agent step.
69pub trait ComponentInvoker: Send + Sync {
70    /// Describe every component operation exposed to agentic-worker tools.
71    fn list_operations(&self) -> Vec<ComponentOperation>;
72
73    /// Invoke one component operation with JSON `args_json`. Returns the raw
74    /// component output value on success, or a stringified error on any
75    /// failure (bad args, instantiation error, component trap, timeout).
76    fn invoke<'a>(
77        &'a self,
78        component_ref: &'a str,
79        operation: &'a str,
80        args_json: &'a str,
81    ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send + 'a>>;
82}
83
84/// Immutable per-tenant view of the component-tool surface. Carries the
85/// LLM-facing schemas (list seam) plus the [`ComponentInvoker`] handle needed
86/// to dispatch a call.
87pub struct ComponentToolCatalog {
88    /// `(component_ref, operation)` → LLM-facing tool schema.
89    tools: HashMap<(String, String), ComponentToolEntry>,
90    invoker: Arc<dyn ComponentInvoker>,
91    fetched_at: Instant,
92}
93
94impl ComponentToolCatalog {
95    fn from_invoker(invoker: Arc<dyn ComponentInvoker>) -> Self {
96        let mut tools = HashMap::new();
97        for op in invoker.list_operations() {
98            tools.insert(
99                (op.component_ref, op.operation),
100                ComponentToolEntry {
101                    description: op.description,
102                    parameters: op.parameters,
103                },
104            );
105        }
106        Self {
107            tools,
108            invoker,
109            fetched_at: Instant::now(),
110        }
111    }
112
113    /// Iterate every `(component_ref, operation)` key with its schema.
114    pub fn tools(&self) -> impl Iterator<Item = (&(String, String), &ComponentToolEntry)> {
115        self.tools.iter()
116    }
117
118    /// Number of operations in the catalog.
119    pub fn len(&self) -> usize {
120        self.tools.len()
121    }
122
123    /// Whether the catalog exposes no operations.
124    pub fn is_empty(&self) -> bool {
125        self.tools.is_empty()
126    }
127
128    /// LLM-facing schema for one operation, if present.
129    pub fn tool_entry(&self, component_ref: &str, operation: &str) -> Option<&ComponentToolEntry> {
130        self.tools
131            .get(&(component_ref.to_string(), operation.to_string()))
132    }
133
134    /// Invoke one component operation, always returning a JSON value. An
135    /// unknown `(component_ref, operation)` or an invoker failure is surfaced
136    /// as `{"error": "..."}` so the LLM observes it as a normal tool result.
137    pub async fn dispatch(
138        &self,
139        component_ref: &str,
140        operation: &str,
141        args_json: &str,
142    ) -> serde_json::Value {
143        if self.tool_entry(component_ref, operation).is_none() {
144            return json!({
145                "error": format!("unknown component tool '{component_ref}/{operation}'")
146            });
147        }
148        match self
149            .invoker
150            .invoke(component_ref, operation, args_json)
151            .await
152        {
153            Ok(value) => value,
154            Err(e) => json!({ "error": e }),
155        }
156    }
157
158    /// Build a catalog directly from a tool map + invoker, bypassing
159    /// [`ComponentToolSource`]. Test-only: lets `tools.rs` exercise the
160    /// list/dispatch seams without standing up a real invoker.
161    #[cfg(test)]
162    pub(crate) fn for_tests(
163        tools: HashMap<(String, String), ComponentToolEntry>,
164        invoker: Arc<dyn ComponentInvoker>,
165    ) -> Self {
166        Self {
167            tools,
168            invoker,
169            fetched_at: Instant::now(),
170        }
171    }
172}
173
174/// Per-tenant, TTL-gated source of agentic-worker component tool catalogs.
175///
176/// Mirrors [`crate::mcp_source::McpToolSource`]: a built catalog is cached per
177/// tenant behind a short TTL so the per-step resolution in
178/// [`crate::r#loop::run_step`] does not re-enumerate the pack's components on
179/// every iteration. The [`ComponentInvoker`] is the host-injected seam over
180/// the pack component runtime.
181pub struct ComponentToolSource {
182    invoker: Arc<dyn ComponentInvoker>,
183    cache: DashMap<String, Arc<ComponentToolCatalog>>,
184}
185
186impl ComponentToolSource {
187    /// Construct a source over a host-provided component invoker.
188    pub fn new(invoker: Arc<dyn ComponentInvoker>) -> Self {
189        Self {
190            invoker,
191            cache: DashMap::new(),
192        }
193    }
194
195    /// Stable per-tenant cache key — the same `(tenant_id, env_id)` pair
196    /// `TenantContext::key_prefix` is built from.
197    fn cache_key(tenant: &TenantContext) -> String {
198        format!("{}:{}", tenant.tenant_id, tenant.env_id)
199    }
200
201    /// Return the tenant's component tool catalog, rebuilding when stale or
202    /// absent. Infallible by contract.
203    pub async fn catalog(&self, tenant: &TenantContext) -> Arc<ComponentToolCatalog> {
204        let key = Self::cache_key(tenant);
205
206        if let Some(entry) = self.cache.get(&key) {
207            let snap = entry.value();
208            if snap.fetched_at.elapsed() < CATALOG_TTL {
209                return snap.clone();
210            }
211        }
212
213        let built = Arc::new(ComponentToolCatalog::from_invoker(self.invoker.clone()));
214        self.cache.insert(key, built.clone());
215        built
216    }
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221pub(crate) mod test_support {
222    //! Test-only fakes shared with `tools.rs` tests.
223    use super::*;
224    use std::sync::atomic::{AtomicUsize, Ordering};
225
226    /// A scriptable [`ComponentInvoker`]: returns a fixed operation list and a
227    /// fixed `invoke` result, and counts `list_operations` calls so the TTL
228    /// cache can be asserted.
229    pub(crate) struct FakeInvoker {
230        ops: Vec<ComponentOperation>,
231        result: Result<serde_json::Value, String>,
232        pub list_calls: AtomicUsize,
233    }
234
235    impl FakeInvoker {
236        pub(crate) fn new(
237            ops: Vec<ComponentOperation>,
238            result: Result<serde_json::Value, String>,
239        ) -> Self {
240            Self {
241                ops,
242                result,
243                list_calls: AtomicUsize::new(0),
244            }
245        }
246    }
247
248    impl ComponentInvoker for FakeInvoker {
249        fn list_operations(&self) -> Vec<ComponentOperation> {
250            self.list_calls.fetch_add(1, Ordering::SeqCst);
251            self.ops.clone()
252        }
253
254        fn invoke<'a>(
255            &'a self,
256            _component_ref: &'a str,
257            _operation: &'a str,
258            _args_json: &'a str,
259        ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send + 'a>> {
260            let result = self.result.clone();
261            Box::pin(async move { result })
262        }
263    }
264
265    /// Build a `ComponentOperation` with an object input schema.
266    pub(crate) fn op(
267        component_ref: &str,
268        operation: &str,
269        description: &str,
270    ) -> ComponentOperation {
271        ComponentOperation {
272            component_ref: component_ref.to_string(),
273            operation: operation.to_string(),
274            description: description.to_string(),
275            parameters: json!({ "type": "object", "properties": {} }),
276        }
277    }
278
279    /// A one-entry tool map for `ComponentToolCatalog::for_tests`.
280    pub(crate) fn one_tool(
281        component_ref: &str,
282        operation: &str,
283        description: &str,
284        parameters: serde_json::Value,
285    ) -> HashMap<(String, String), ComponentToolEntry> {
286        let mut m = HashMap::new();
287        m.insert(
288            (component_ref.to_string(), operation.to_string()),
289            ComponentToolEntry {
290                description: description.to_string(),
291                parameters,
292            },
293        );
294        m
295    }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301    use super::test_support::*;
302    use super::*;
303
304    fn tenant() -> TenantContext {
305        TenantContext::new("acme", "prod")
306    }
307
308    #[tokio::test]
309    async fn source_lists_component_operations() {
310        let invoker = Arc::new(FakeInvoker::new(
311            vec![
312                op("greentic.refund", "issue_refund", "Issue a refund"),
313                op("greentic.refund", "lookup_order", "Look up an order"),
314            ],
315            Ok(json!({})),
316        ));
317        let source = ComponentToolSource::new(invoker);
318        let catalog = source.catalog(&tenant()).await;
319
320        assert_eq!(catalog.len(), 2);
321        let entry = catalog
322            .tool_entry("greentic.refund", "issue_refund")
323            .expect("operation present");
324        assert_eq!(entry.description, "Issue a refund");
325        assert!(
326            catalog
327                .tool_entry("greentic.refund", "lookup_order")
328                .is_some()
329        );
330        assert!(catalog.tool_entry("greentic.refund", "absent").is_none());
331    }
332
333    #[tokio::test]
334    async fn dispatch_returns_component_value_on_success() {
335        let invoker = Arc::new(FakeInvoker::new(
336            vec![op("greentic.refund", "issue_refund", "Issue a refund")],
337            Ok(json!({ "refund_id": "r-1" })),
338        ));
339        let source = ComponentToolSource::new(invoker);
340        let catalog = source.catalog(&tenant()).await;
341
342        let out = catalog
343            .dispatch("greentic.refund", "issue_refund", "{}")
344            .await;
345        assert_eq!(out, json!({ "refund_id": "r-1" }), "got: {out}");
346        assert!(!out.to_string().contains("error"), "got: {out}");
347    }
348
349    #[tokio::test]
350    async fn dispatch_wraps_invoker_error() {
351        let invoker = Arc::new(FakeInvoker::new(
352            vec![op("greentic.refund", "issue_refund", "Issue a refund")],
353            Err("component trapped".to_string()),
354        ));
355        let source = ComponentToolSource::new(invoker);
356        let catalog = source.catalog(&tenant()).await;
357
358        let out = catalog
359            .dispatch("greentic.refund", "issue_refund", "{}")
360            .await;
361        assert_eq!(out, json!({ "error": "component trapped" }), "got: {out}");
362    }
363
364    #[tokio::test]
365    async fn dispatch_unknown_operation_errors_without_invoking() {
366        let invoker = Arc::new(FakeInvoker::new(
367            vec![op("greentic.refund", "issue_refund", "Issue a refund")],
368            Ok(json!({ "should": "not be returned" })),
369        ));
370        let source = ComponentToolSource::new(invoker);
371        let catalog = source.catalog(&tenant()).await;
372
373        // An operation not in the catalog must not reach the invoker.
374        let out = catalog.dispatch("greentic.refund", "no_such", "{}").await;
375        assert!(out.to_string().contains("error"), "got: {out}");
376        assert!(
377            out.to_string().contains("greentic.refund/no_such"),
378            "got: {out}"
379        );
380    }
381
382    #[tokio::test]
383    async fn ttl_cache_reuses_within_window() {
384        let invoker = Arc::new(FakeInvoker::new(
385            vec![op("greentic.refund", "issue_refund", "Issue a refund")],
386            Ok(json!({})),
387        ));
388        let source = ComponentToolSource::new(invoker.clone());
389        let t = tenant();
390        let first = source.catalog(&t).await;
391        let second = source.catalog(&t).await;
392
393        assert!(
394            Arc::ptr_eq(&first, &second),
395            "second call must hit TTL cache"
396        );
397        assert_eq!(
398            invoker.list_calls.load(std::sync::atomic::Ordering::SeqCst),
399            1,
400            "operations enumerated exactly once within the TTL window"
401        );
402    }
403
404    #[tokio::test]
405    async fn for_tests_builds_catalog_with_entry() {
406        let invoker = Arc::new(FakeInvoker::new(vec![], Ok(json!({ "ok": true }))));
407        let catalog = ComponentToolCatalog::for_tests(
408            one_tool(
409                "greentic.refund",
410                "issue_refund",
411                "Issue a refund",
412                json!({ "type": "object" }),
413            ),
414            invoker,
415        );
416        assert_eq!(catalog.len(), 1);
417        let out = catalog
418            .dispatch("greentic.refund", "issue_refund", "{}")
419            .await;
420        assert_eq!(out, json!({ "ok": true }), "got: {out}");
421    }
422}