Skip to main content

aw_event_bridge/
lib.rs

1//! Event bridge: consume `greentic.agentic.request.v1` from NATS, invoke the
2//! local agentic-worker runtime via the [`AgentDispatchInvoker`] seam, and
3//! publish `greentic.agentic.response.v1` echoing the correlation id.
4//!
5//! This is the agentic-side counterpart of the runner's `agentic.call` flow
6//! node (out-of-process path, "Option C"): the runner publishes a dispatch
7//! request; this bridge runs one agentic step and publishes the reply.
8//!
9//! Unlike `sorx-event-bridge`, this crate lives inside the runner workspace and
10//! pins the same `greentic-types` lineage as the runner, so the wire contract is
11//! SHARED from [`greentic_types::runtime_dispatch`] rather than mirrored by hand.
12
13pub mod jetstream;
14
15// Re-export so callers can use `aw_event_bridge::run_bridge_jetstream` without
16// naming the submodule explicitly.
17pub use jetstream::run_bridge_jetstream;
18
19use std::sync::Arc;
20
21use anyhow::Result;
22use async_nats::HeaderMap;
23use async_trait::async_trait;
24use greentic_types::{DispatchError, RuntimeDispatchRequest, RuntimeDispatchResponse};
25use serde_json::Value;
26
27// Re-export the shared subject helpers so serve-mode callers can name the
28// agentic subjects without an extra greentic-types dependency.
29pub use greentic_types::{request_topic, response_topic};
30
31/// Runtime name for the agentic worker; selects the request/response subjects
32/// `greentic.agentic.request.v1` / `greentic.agentic.response.v1`.
33pub const RUNTIME_NAME: &str = "agentic";
34
35/// Result of invoking the local agentic-worker runtime for one dispatch.
36pub struct InvokeOutcome {
37    /// Whether the step completed successfully.
38    pub ok: bool,
39    /// Step output payload (e.g. `{reply, trail, terminated_by}`).
40    pub output: Value,
41    /// Optional runtime-emitted events (empty for the agentic worker today).
42    pub events: Vec<Value>,
43}
44
45/// Seam over the actual agentic-worker invocation. The production impl wraps
46/// `greentic_aw_runtime::AgentRuntime`; tests use a stub.
47///
48/// `target` is the agent id (the runner's `agentic.call.<agent_id>` node maps
49/// the node target to this field); `operation` is reserved for future
50/// multi-operation agents and may be empty. `input` is the opaque node input —
51/// the production invoker extracts `user_text` from it exactly as the in-process
52/// `agent_node` path does.
53#[async_trait]
54pub trait AgentDispatchInvoker: Send + Sync {
55    /// Run one agentic step.
56    ///
57    /// * `tenant` / `env` — multi-tenant context echoed from the request headers.
58    /// * `target` — the agent id.
59    /// * `operation` — reserved; may be empty.
60    /// * `input` — opaque node input (expects at least `{"user_text": "..."}`).
61    /// * `idempotency_key` — correlation/idempotency hint; doubles as the
62    ///   session id when the input carries no explicit `session_id`.
63    async fn invoke(
64        &self,
65        tenant: &str,
66        env: &str,
67        target: &str,
68        operation: &str,
69        input: Value,
70        idempotency_key: Option<&str>,
71    ) -> Result<InvokeOutcome>;
72}
73
74/// Invoke and build the response (no NATS I/O). Errors map to an error response.
75pub async fn build_response(
76    invoker: Arc<dyn AgentDispatchInvoker>,
77    tenant: &str,
78    env: &str,
79    idempotency_key: Option<&str>,
80    req: RuntimeDispatchRequest,
81) -> RuntimeDispatchResponse {
82    match invoker
83        .invoke(
84            tenant,
85            env,
86            &req.target,
87            &req.operation,
88            req.input,
89            idempotency_key,
90        )
91        .await
92    {
93        Ok(outcome) => RuntimeDispatchResponse {
94            ok: outcome.ok,
95            output: outcome.output,
96            events: outcome.events,
97            error: None,
98        },
99        Err(error) => RuntimeDispatchResponse {
100            ok: false,
101            output: Value::Null,
102            events: vec![],
103            error: Some(DispatchError {
104                code: "invoke_failed".into(),
105                message: error.to_string(),
106            }),
107        },
108    }
109}
110
111/// Handle one request message end-to-end: decode, invoke, publish response.
112///
113/// The correlation id is echoed VERBATIM (the runner's `agentic.call` node
114/// encodes `::pack=…::flow=…::thread=…::reply=…` resume markers there and parses
115/// them back on response, so the bridge must not alter it).
116pub async fn handle_message(
117    client: &async_nats::Client,
118    invoker: Arc<dyn AgentDispatchInvoker>,
119    msg: async_nats::Message,
120) -> Result<()> {
121    let headers = msg.headers.as_ref();
122    let get_header = |name: &str| -> Option<String> {
123        headers
124            .and_then(|header_map| header_map.get(name))
125            .map(|value| value.as_str().to_string())
126    };
127
128    let correlation = get_header("Greentic-Correlation-Id");
129    // The runner sets the idempotency key equal to the correlation id; prefer the
130    // explicit header but fall back to the correlation id so the invoker always
131    // has a stable session hint.
132    let idempotency = get_header("Greentic-Idempotency-Key").or_else(|| correlation.clone());
133    let tenant = get_header("Greentic-Tenant").unwrap_or_default();
134    let env = get_header("Greentic-Env").unwrap_or_else(|| "default".to_string());
135
136    let req: RuntimeDispatchRequest = serde_json::from_slice(&msg.payload)?;
137    let resp = build_response(invoker, &tenant, &env, idempotency.as_deref(), req).await;
138
139    let mut out_headers = HeaderMap::new();
140    if let Some(correlation_value) = correlation.as_deref() {
141        out_headers.insert("Greentic-Correlation-Id", correlation_value);
142    }
143    out_headers.insert("Greentic-Tenant", tenant.as_str());
144    out_headers.insert("Greentic-Env", env.as_str());
145
146    let response_bytes = serde_json::to_vec(&resp)?;
147    client
148        .publish_with_headers(
149            response_topic(RUNTIME_NAME),
150            out_headers,
151            response_bytes.into(),
152        )
153        .await?;
154    Ok(())
155}
156
157/// Subscribe to `greentic.agentic.request.v1` and serve forever (one spawned
158/// task per message).
159pub async fn run_bridge(
160    client: async_nats::Client,
161    invoker: Arc<dyn AgentDispatchInvoker>,
162) -> Result<()> {
163    use futures_util::StreamExt;
164    let mut subscriber = client.subscribe(request_topic(RUNTIME_NAME)).await?;
165    while let Some(msg) = subscriber.next().await {
166        let client = client.clone();
167        let invoker = invoker.clone();
168        tokio::spawn(async move {
169            if let Err(error) = handle_message(&client, invoker, msg).await {
170                tracing::error!(%error, "aw event bridge failed to handle request");
171            }
172        });
173    }
174    Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use greentic_types::DispatchMode;
181    use serde_json::json;
182    use std::sync::Mutex;
183
184    /// (tenant/env elided, target, operation, payload, idempotency_key)
185    type SeenCall = (String, String, Value, Option<String>);
186
187    struct StubInvoker {
188        seen: Mutex<Vec<SeenCall>>,
189    }
190
191    #[async_trait]
192    impl AgentDispatchInvoker for StubInvoker {
193        async fn invoke(
194            &self,
195            _tenant: &str,
196            _env: &str,
197            target: &str,
198            operation: &str,
199            input: Value,
200            idempotency_key: Option<&str>,
201        ) -> Result<InvokeOutcome> {
202            self.seen.lock().unwrap().push((
203                target.to_string(),
204                operation.to_string(),
205                input.clone(),
206                idempotency_key.map(str::to_string),
207            ));
208            Ok(InvokeOutcome {
209                ok: true,
210                output: json!({"reply": "pong", "trail": [], "terminated_by": "reply"}),
211                events: vec![],
212            })
213        }
214    }
215
216    fn sample_request() -> RuntimeDispatchRequest {
217        RuntimeDispatchRequest {
218            target: "greeter".into(),
219            operation: String::new(),
220            mode: DispatchMode::Await,
221            input: json!({"user_text": "ping"}),
222            deadline_ms: Some(30_000),
223        }
224    }
225
226    #[test]
227    fn subjects_use_agentic_runtime_name() {
228        assert_eq!(request_topic(RUNTIME_NAME), "greentic.agentic.request.v1");
229        assert_eq!(response_topic(RUNTIME_NAME), "greentic.agentic.response.v1");
230    }
231
232    #[tokio::test]
233    async fn handle_invokes_and_maps_agent_output_to_response() {
234        let invoker = Arc::new(StubInvoker {
235            seen: Mutex::new(vec![]),
236        });
237        let resp = build_response(
238            invoker.clone(),
239            "acme",
240            "prod",
241            Some("sess-1::pack=p::flow=f"),
242            sample_request(),
243        )
244        .await;
245
246        assert!(resp.ok);
247        assert_eq!(resp.output["reply"], json!("pong"));
248        assert_eq!(resp.output["terminated_by"], json!("reply"));
249        assert!(resp.error.is_none());
250
251        let seen = invoker.seen.lock().unwrap();
252        assert_eq!(seen.len(), 1);
253        let (target, _operation, input, idempotency) = &seen[0];
254        assert_eq!(target, "greeter", "dispatch target maps to agent id");
255        assert_eq!(input["user_text"], json!("ping"));
256        assert_eq!(
257            idempotency.as_deref(),
258            Some("sess-1::pack=p::flow=f"),
259            "correlation/idempotency hint is forwarded to the invoker"
260        );
261    }
262
263    #[tokio::test]
264    async fn invoke_error_maps_to_error_response() {
265        struct FailInvoker;
266
267        #[async_trait]
268        impl AgentDispatchInvoker for FailInvoker {
269            async fn invoke(
270                &self,
271                _tenant: &str,
272                _env: &str,
273                _target: &str,
274                _operation: &str,
275                _input: Value,
276                _idempotency_key: Option<&str>,
277            ) -> Result<InvokeOutcome> {
278                Err(anyhow::anyhow!("boom"))
279            }
280        }
281
282        let resp = build_response(
283            Arc::new(FailInvoker),
284            "acme",
285            "prod",
286            Some("c"),
287            sample_request(),
288        )
289        .await;
290
291        assert!(!resp.ok);
292        assert_eq!(resp.output, Value::Null);
293        let error = resp.error.expect("error response must carry details");
294        assert_eq!(error.code, "invoke_failed");
295        assert_eq!(error.message, "boom");
296    }
297}