Skip to main content

greentic_runner_host/runner/
component_invoker.rs

1//! Runner-host implementation of `greentic_aw_runtime::ComponentInvoker`.
2//!
3//! Exposes the components of the loaded packs to an agentic worker as LLM tools
4//! and invokes them over the canonical `PackRuntime` component host — the same
5//! instantiation / WASI / secrets path a normal flow `component.*` node uses.
6//! This is the runner-host half of the `component:` tool seam; the aw-runtime
7//! half (`ComponentToolSource`/`ComponentToolCatalog`) depends only on the
8//! trait + JSON, so this is where `PackRuntime`/`wasmtime` enter.
9//!
10//! The runtime is built per operator config, so the operator `tenant` is
11//! captured at construction and stamped on the component exec context.
12
13#![cfg(feature = "agentic-worker")]
14
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19use greentic_aw_runtime::{ComponentInvoker, ComponentOperation};
20use serde_json::Value;
21
22use crate::component_api::node::{ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx};
23use crate::pack::PackRuntime;
24use crate::runner::invocation::{InvocationMeta, build_invocation_envelope};
25
26/// Synthetic flow id stamped on component tool invocations so the host
27/// component runtime sees a stable origin for an agentic-worker tool call.
28const COMPONENT_TOOL_FLOW_ID: &str = "dw.agent";
29
30/// `ComponentInvoker` backed by the loaded packs' component host.
31pub struct PackRuntimeComponentInvoker {
32    packs: Vec<Arc<PackRuntime>>,
33    tenant: String,
34    env: String,
35}
36
37impl PackRuntimeComponentInvoker {
38    /// Construct over the operator's loaded packs + tenant. `GREENTIC_ENV`
39    /// (default `local`) selects the invocation env, matching `PackRuntime`.
40    pub fn new(packs: Vec<Arc<PackRuntime>>, tenant: String) -> Self {
41        let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
42        Self { packs, tenant, env }
43    }
44}
45
46/// Synthesize an LLM-facing description (the component manifest carries no
47/// per-operation prose today).
48fn describe_operation(component_ref: &str, operation: &str) -> String {
49    format!("Invoke operation '{operation}' of greentic component '{component_ref}'.")
50}
51
52/// Map a component's manifest operations to LLM-facing tool operations.
53fn map_operations(
54    component_ref: &str,
55    operations: &[greentic_types::ComponentOperation],
56) -> Vec<ComponentOperation> {
57    operations
58        .iter()
59        .map(|op| ComponentOperation {
60            component_ref: component_ref.to_string(),
61            operation: op.name.clone(),
62            description: describe_operation(component_ref, &op.name),
63            parameters: op.input_schema.clone(),
64        })
65        .collect()
66}
67
68/// Build the component-host exec context for an agentic-worker tool call.
69fn build_exec_ctx(tenant: &str, component_ref: &str) -> ComponentExecCtx {
70    ComponentExecCtx {
71        tenant: ComponentTenantCtx {
72            tenant: tenant.to_string(),
73            team: None,
74            user: None,
75            trace_id: None,
76            i18n_id: None,
77            correlation_id: None,
78            deadline_unix_ms: None,
79            attempt: 1,
80            idempotency_key: None,
81        },
82        i18n_id: None,
83        flow_id: COMPONENT_TOOL_FLOW_ID.to_string(),
84        node_id: Some(component_ref.to_string()),
85    }
86}
87
88/// Wrap the LLM-supplied JSON args into the component invocation envelope the
89/// host runtime expects (mirrors the flow-engine component callsite).
90fn build_invocation_input(
91    env: &str,
92    tenant: &str,
93    component_ref: &str,
94    operation: &str,
95    args_json: &str,
96) -> Result<String, String> {
97    let payload: Value = serde_json::from_str(args_json)
98        .map_err(|e| format!("invalid component tool arguments: {e}"))?;
99    let meta = InvocationMeta {
100        env,
101        tenant,
102        flow_id: COMPONENT_TOOL_FLOW_ID,
103        node_id: Some(component_ref),
104        provider_id: None,
105        session_id: None,
106        attempt: 1,
107    };
108    let envelope = build_invocation_envelope(meta, operation, payload)
109        .map_err(|e| format!("build invocation envelope: {e}"))?;
110    serde_json::to_string(&envelope).map_err(|e| format!("encode invocation envelope: {e}"))
111}
112
113impl ComponentInvoker for PackRuntimeComponentInvoker {
114    fn list_operations(&self) -> Vec<ComponentOperation> {
115        let mut out = Vec::new();
116        for pack in &self.packs {
117            for (component_ref, manifest) in pack.component_manifest_entries() {
118                out.extend(map_operations(component_ref, &manifest.operations));
119            }
120        }
121        out
122    }
123
124    fn invoke<'a>(
125        &'a self,
126        component_ref: &'a str,
127        operation: &'a str,
128        args_json: &'a str,
129    ) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>> {
130        Box::pin(async move {
131            let Some(pack) = self
132                .packs
133                .iter()
134                .find(|p| p.contains_component(component_ref))
135            else {
136                return Err(format!(
137                    "component '{component_ref}' not found in any loaded pack"
138                ));
139            };
140            let exec_ctx = build_exec_ctx(&self.tenant, component_ref);
141            let input_json = build_invocation_input(
142                &self.env,
143                &self.tenant,
144                component_ref,
145                operation,
146                args_json,
147            )?;
148            pack.invoke_component(component_ref, exec_ctx, operation, None, input_json)
149                .await
150                .map_err(|e| format!("component '{component_ref}' invoke failed: {e}"))
151        })
152    }
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used, clippy::expect_used)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    fn gt_op(name: &str, input_schema: Value) -> greentic_types::ComponentOperation {
162        greentic_types::ComponentOperation {
163            name: name.to_string(),
164            input_schema,
165            output_schema: json!(null),
166        }
167    }
168
169    #[test]
170    fn describe_operation_names_component_and_operation() {
171        let d = describe_operation("greentic.refund", "issue_refund");
172        assert!(d.contains("greentic.refund"), "got: {d}");
173        assert!(d.contains("issue_refund"), "got: {d}");
174    }
175
176    #[test]
177    fn map_operations_projects_name_and_input_schema() {
178        let params =
179            json!({ "type": "object", "properties": { "order_id": { "type": "string" } } });
180        let ops = vec![
181            gt_op("issue_refund", params.clone()),
182            gt_op("lookup_order", json!({ "type": "object" })),
183        ];
184        let mapped = map_operations("greentic.refund", &ops);
185
186        assert_eq!(mapped.len(), 2);
187        let refund = mapped
188            .iter()
189            .find(|o| o.operation == "issue_refund")
190            .expect("issue_refund mapped");
191        assert_eq!(refund.component_ref, "greentic.refund");
192        assert_eq!(refund.parameters, params);
193        assert!(!refund.description.is_empty());
194    }
195
196    #[test]
197    fn build_exec_ctx_stamps_tenant_and_flow() {
198        let ctx = build_exec_ctx("acme", "greentic.refund");
199        assert_eq!(ctx.tenant.tenant, "acme");
200        assert_eq!(ctx.flow_id, COMPONENT_TOOL_FLOW_ID);
201        assert_eq!(ctx.node_id.as_deref(), Some("greentic.refund"));
202    }
203
204    #[test]
205    fn build_invocation_input_wraps_args_as_envelope() {
206        let input = build_invocation_input(
207            "local",
208            "acme",
209            "greentic.refund",
210            "issue_refund",
211            r#"{"order_id":"42"}"#,
212        )
213        .expect("envelope built");
214        let parsed: Value = serde_json::from_str(&input).expect("valid json");
215        assert_eq!(parsed["op"], json!("issue_refund"), "got: {parsed}");
216        assert_eq!(
217            parsed["flow_id"],
218            json!(COMPONENT_TOOL_FLOW_ID),
219            "got: {parsed}"
220        );
221    }
222
223    #[test]
224    fn build_invocation_input_rejects_bad_args() {
225        let err = build_invocation_input("local", "acme", "c", "op", "not json")
226            .expect_err("invalid args must error");
227        assert!(
228            err.contains("invalid component tool arguments"),
229            "got: {err}"
230        );
231    }
232}