greentic_runner_host/runner/
component_invoker.rs1#![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
26const COMPONENT_TOOL_FLOW_ID: &str = "dw.agent";
29
30pub struct PackRuntimeComponentInvoker {
32 packs: Vec<Arc<PackRuntime>>,
33 tenant: String,
34 env: String,
35}
36
37impl PackRuntimeComponentInvoker {
38 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
46fn describe_operation(component_ref: &str, operation: &str) -> String {
49 format!("Invoke operation '{operation}' of greentic component '{component_ref}'.")
50}
51
52fn 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
68fn 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
88fn 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}