1use anyhow::Context;
2use async_nats::Client as NatsClient;
3use mesh_types::AgentCard;
4use reqwest::Client as HttpClient;
5use serde_json::json;
6use std::time::Duration;
7use uuid::Uuid;
8use futures::StreamExt;
9use futures::future::BoxFuture;
10use tokio::sync::watch;
11
12#[derive(Clone)]
13pub struct MeshClientConfig {
14 pub nats_url: String,
15 pub registry_url: String,
16 pub did: String,
17 pub region: Option<String>,
18}
19
20pub struct RegistryClient {
21 base: String,
22 http: HttpClient,
23}
24
25impl RegistryClient {
26 pub fn new(base: impl Into<String>) -> Self {
27 Self { base: base.into(), http: HttpClient::new() }
28 }
29
30 pub async fn get_agent(&self, did: &str) -> Result<AgentCard, anyhow::Error> {
31 let url = format!("{}/agents/{}", self.base.trim_end_matches('/'), did);
32 let res = self
33 .http
34 .get(&url)
35 .send()
36 .await
37 .with_context(|| format!("failed to GET {}", url))?;
38 let status = res.status();
39 let body = res.text().await?;
40 if !status.is_success() {
41 return Err(anyhow::anyhow!("registry GET failed: {} {}", status, body));
42 }
43 let card: AgentCard = serde_json::from_str(&body)?;
44 Ok(card)
45 }
46}
47
48pub struct MeshClient {
49 cfg: MeshClientConfig,
50 nats: NatsClient,
51 registry: RegistryClient,
52}
53
54pub struct RequestOptions {
55 pub domain: Vec<String>,
56 pub capability_id: String,
57 pub description: Option<String>,
58 pub timeout_ms: Option<u64>,
59}
60
61pub type MatchHandler = Box<dyn Fn(mesh_types::MatchOrReject) -> BoxFuture<'static, ()> + Send + Sync + 'static>;
62
63pub struct ListenSubscription {
64 stop_tx: watch::Sender<bool>,
65}
66
67impl ListenSubscription {
68 pub async fn unsubscribe(self) {
69 let _ = self.stop_tx.send(true);
70 }
71}
72
73pub struct HeartbeatHandle {
74 stop_tx: watch::Sender<bool>,
75}
76
77impl HeartbeatHandle {
78 pub async fn stop(self) {
79 let _ = self.stop_tx.send(true);
80 }
81}
82
83impl MeshClient {
84 pub async fn new(cfg: MeshClientConfig) -> Result<Self, anyhow::Error> {
85 let nats = async_nats::connect(&cfg.nats_url)
86 .await
87 .with_context(|| format!("connect to nats at {}", &cfg.nats_url))?;
88 let registry = RegistryClient::new(cfg.registry_url.clone());
89 Ok(Self { cfg, nats, registry })
90 }
91
92 pub async fn register(&self, card: &AgentCard) -> Result<(), anyhow::Error> {
93 mesh_types::validate_agent_card(card).map_err(|e| anyhow::anyhow!("invalid agent card: {}", e))?;
95
96 let url = format!("{}/agents", self.cfg.registry_url.trim_end_matches('/'));
97 let res = self
98 .registry
99 .http
100 .post(&url)
101 .json(card)
102 .send()
103 .await
104 .context("failed to POST agent card")?;
105 let status = res.status();
106 if !status.is_success() {
107 let body = res.text().await.unwrap_or_default();
108 return Err(anyhow::anyhow!("register failed: {} {}", status, body));
109 }
110 Ok(())
111 }
112
113 pub async fn request(&self, opts: RequestOptions) -> Result<mesh_types::MatchOrReject, anyhow::Error> {
116 let region = self.cfg.region.clone().unwrap_or_else(|| "global".into());
117 let subject = format!("mesh.requests.{}.{}", opts.domain.join("."), region);
118
119 let event_id = Uuid::new_v4().to_string();
120 let cloud_event = json!({
121 "specversion": "1.0",
122 "type": "amp.capability.request",
123 "source": self.cfg.did,
124 "id": event_id,
125 "time": chrono::Utc::now().to_rfc3339(),
126 "data": {
127 "capability_id": opts.capability_id,
128 "description": opts.description,
129 }
130 });
131
132 let payload = serde_json::to_vec(&cloud_event)?;
133
134 let timeout = Duration::from_millis(opts.timeout_ms.unwrap_or(30_000));
135
136 let req_fut = self.nats.request(subject, payload.into());
137 let msg = tokio::time::timeout(timeout, req_fut)
138 .await
139 .map_err(|_| anyhow::anyhow!("request timeout"))??;
140
141 let reply = String::from_utf8_lossy(&msg.payload).to_string();
142 let json: serde_json::Value = serde_json::from_str(&reply).context("parse reply JSON")?;
143 let parsed = mesh_types::parse_match_or_reject(&json).map_err(|e| anyhow::anyhow!("parse reply: {}", e))?;
144 Ok(parsed)
145 }
146
147 fn sanitize_did(did: &str) -> String {
148 did.replace(':', "_")
149 }
150
151 pub async fn listen(&self, handler: MatchHandler) -> Result<ListenSubscription, anyhow::Error> {
154 let subj = format!("mesh.matches.{}", Self::sanitize_did(&self.cfg.did));
155 let mut sub = self.nats.subscribe(subj).await.context("subscribe failed")?;
156
157 let (stop_tx, mut stop_rx) = watch::channel(false);
158
159 tokio::spawn(async move {
160 loop {
161 tokio::select! {
162 maybe = sub.next() => {
163 match maybe {
164 Some(msg) => {
165 let payload = String::from_utf8_lossy(&msg.payload).to_string();
166 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {
167 if let Ok(parsed) = mesh_types::parse_match_or_reject(&json) {
168 (handler)(parsed).await;
169 }
170 }
171 }
172 None => break,
173 }
174 }
175 _ = stop_rx.changed() => {
176 if *stop_rx.borrow() { break; }
177 }
178 }
179 }
180 });
181
182 Ok(ListenSubscription { stop_tx })
183 }
184
185 pub async fn start_heartbeat(&self, interval_ms: u64) -> Result<HeartbeatHandle, anyhow::Error> {
187 let subj = format!("mesh.agents.heartbeat.{}", Self::sanitize_did(&self.cfg.did));
188 let (stop_tx, mut stop_rx) = watch::channel(false);
189 let nats = self.nats.clone();
190 let did = self.cfg.did.clone();
191
192 tokio::spawn(async move {
193 let mut interval = tokio::time::interval(Duration::from_millis(interval_ms));
194 loop {
195 tokio::select! {
196 _ = interval.tick() => {
197 let payload = json!({ "did": did, "time": chrono::Utc::now().to_rfc3339() });
198 let _ = nats.publish(subj.clone(), serde_json::to_vec(&payload).unwrap().into()).await;
199 }
200 _ = stop_rx.changed() => {
201 if *stop_rx.borrow() { break; }
202 }
203 }
204 }
205 });
206
207 Ok(HeartbeatHandle { stop_tx })
208 }
209
210 pub async fn close(self) -> Result<(), anyhow::Error> {
211 Ok(())
213 }
214}
215