1use std::time::{Duration, SystemTime};
6
7use rmcp::model::Implementation;
8use serde_json::{Map, Value};
9use tokio::runtime::Handle;
10
11use crate::client::AgentCat;
12use crate::error::Error;
13use crate::event::ClientIdentity;
14use crate::event::assemble::{EventJob, SDK_LANGUAGE};
15use crate::hooks::PendingHooks;
16use crate::pipeline::EventObject;
17use crate::pipeline::tags::validate_tags;
18use crate::wire;
19
20#[cfg(test)]
21mod tests;
22
23#[derive(Clone, Debug, Default)]
37pub struct CustomEvent {
38 pub(crate) session_id: Option<String>,
39 pub(crate) resource_name: Option<String>,
40 pub(crate) parameters: Option<Value>,
41 pub(crate) response: Option<Value>,
42 pub(crate) message: Option<String>,
43 pub(crate) duration: Option<Duration>,
44 pub(crate) is_error: Option<bool>,
45 pub(crate) error: Option<Value>,
46 pub(crate) tags: Option<Map<String, Value>>,
47 pub(crate) properties: Option<Map<String, Value>>,
48}
49
50impl CustomEvent {
51 #[must_use]
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 #[must_use]
59 pub fn session_id(mut self, id: impl Into<String>) -> Self {
60 self.session_id = Some(id.into());
61 self
62 }
63
64 #[must_use]
66 pub fn resource_name(mut self, name: impl Into<String>) -> Self {
67 self.resource_name = Some(name.into());
68 self
69 }
70
71 #[must_use]
73 pub fn parameters(mut self, parameters: Value) -> Self {
74 self.parameters = Some(parameters);
75 self
76 }
77
78 #[must_use]
80 pub fn response(mut self, response: Value) -> Self {
81 self.response = Some(response);
82 self
83 }
84
85 #[must_use]
87 pub fn message(mut self, message: impl Into<String>) -> Self {
88 self.message = Some(message.into());
89 self
90 }
91
92 #[must_use]
94 pub fn duration(mut self, duration: Duration) -> Self {
95 self.duration = Some(duration);
96 self
97 }
98
99 #[must_use]
101 pub fn is_error(mut self, is_error: bool) -> Self {
102 self.is_error = Some(is_error);
103 self
104 }
105
106 #[must_use]
108 pub fn error(mut self, error: Value) -> Self {
109 self.error = Some(error);
110 self
111 }
112
113 #[must_use]
115 pub fn tags(mut self, tags: Map<String, Value>) -> Self {
116 self.tags = Some(tags);
117 self
118 }
119
120 #[must_use]
122 pub fn properties(mut self, properties: Map<String, Value>) -> Self {
123 self.properties = Some(properties);
124 self
125 }
126}
127
128const CUSTOM_EVENT_TYPE: &str = "agentcat:custom";
130
131const NO_SESSION_WARNING: &str = "publish_custom_event: no session_id provided; event will be published without a session. Pass CustomEvent::session_id to attribute it to a task.";
133
134fn put(event: &mut EventObject, key: &str, value: Option<Value>) {
136 if let Some(value) = value {
137 event.insert(key.to_owned(), value);
138 }
139}
140
141fn millis(duration: Duration) -> Value {
143 Value::from(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
144}
145
146struct Payload {
149 resource_name: Option<String>,
150 parameters: Option<Value>,
151 response: Option<Value>,
152 message: Option<String>,
153}
154
155fn put_payload(event: &mut EventObject, payload: Payload) {
158 let Payload {
159 resource_name,
160 parameters,
161 response,
162 message,
163 } = payload;
164 put(event, "resourceName", resource_name.map(Value::String));
165 put(event, "parameters", parameters);
166 put(event, "response", response);
167 put(event, "userIntent", message.map(Value::String));
168}
169
170struct Accepted {
172 session_id: Option<String>,
174 payload: Payload,
175 duration: Option<Duration>,
176 is_error: Option<bool>,
177 error: Option<Value>,
178 tags: Option<Map<String, Value>>,
179 properties: Option<Map<String, Value>>,
180}
181
182fn accept(
189 agentcat: &AgentCat,
190 project_id: &str,
191 event: CustomEvent,
192) -> Result<Option<Accepted>, Error> {
193 if !agentcat.inner().enabled {
194 return Ok(None);
195 }
196 if project_id.is_empty() {
197 return Err(Error::ProjectIdRequired);
198 }
199 let CustomEvent {
200 session_id,
201 resource_name,
202 parameters,
203 response,
204 message,
205 duration,
206 is_error,
207 error,
208 tags,
209 properties,
210 } = event;
211 Ok(Some(Accepted {
212 session_id: session_id.filter(|own| !own.is_empty()),
213 payload: Payload {
214 resource_name,
215 parameters,
216 response,
217 message,
218 },
219 duration,
220 is_error,
221 error,
222 tags,
223 properties,
224 }))
225}
226
227fn put_metadata(
231 event: &mut EventObject,
232 tags: Option<Map<String, Value>>,
233 properties: Option<Map<String, Value>>,
234 log: &mut Vec<String>,
235) {
236 if let Some(tags) = tags {
237 let valid = validate_tags(&tags, log);
238 event.insert("tags".to_owned(), valid.map_or(Value::Null, Value::Object));
239 }
240 if let Some(properties) = properties.filter(|properties| !properties.is_empty()) {
241 event.insert("properties".to_owned(), Value::Object(properties));
242 }
243}
244
245fn hand_over(agentcat: &AgentCat, log: Vec<String>, job: Option<EventJob>, session_id: &str) {
248 let sink = &agentcat.inner().sink;
249 for line in log {
250 sink.log(line);
251 }
252 let whose = if session_id.is_empty() {
253 "without a session".to_owned()
254 } else {
255 format!("for session {session_id}")
256 };
257 let published = format!("Published custom event {whose} with type '{CUSTOM_EVENT_TYPE}'");
258 match job {
259 Some(job) => sink.event_then_log(job, published),
260 None => sink.log(published),
261 }
262}
263
264pub(crate) fn publish_string(
273 agentcat: &AgentCat,
274 session_id: &str,
275 project_id: &str,
276 event: CustomEvent,
277 now: SystemTime,
278) -> Result<(), Error> {
279 let Some(event) = accept(agentcat, project_id, event)? else {
280 return Ok(());
281 };
282 let session = event.session_id.unwrap_or_else(|| session_id.to_owned());
283
284 let mut log = Vec::new();
285 let mut object = EventObject::new();
286 object.insert("sessionId".to_owned(), Value::String(session.clone()));
287 object.insert("projectId".to_owned(), Value::String(project_id.to_owned()));
288 object.insert("eventType".to_owned(), Value::from(CUSTOM_EVENT_TYPE));
289 object.insert("timestamp".to_owned(), Value::String(wire::iso_millis(now)));
290 put_payload(&mut object, event.payload);
291 put(&mut object, "duration", event.duration.map(millis));
292 put(&mut object, "isError", event.is_error.map(Value::Bool));
293 put(&mut object, "error", event.error);
294 put_metadata(&mut object, event.tags, event.properties, &mut log);
295
296 let job = EventJob {
297 event: object,
298 hooks: PendingHooks::default(),
299 hook_mode: false,
300 runtime: None,
301 string_redaction: false,
302 event_redaction: false,
303 backtrace: None,
304 };
305 hand_over(agentcat, log, Some(job), &session);
306 Ok(())
307}
308
309pub(crate) fn publish_server(
320 agentcat: &AgentCat,
321 project_id: &str,
322 event: CustomEvent,
323 client: Option<&ClientIdentity>,
324 server: Option<&Implementation>,
325 created: SystemTime,
326 published: SystemTime,
327) -> Result<(), Error> {
328 let Some(event) = accept(agentcat, project_id, event)? else {
329 return Ok(());
330 };
331 let mut log = Vec::new();
332 let session = event.session_id.unwrap_or_else(|| {
333 log.push(NO_SESSION_WARNING.to_owned());
334 String::new()
335 });
336 let mut custom = EventObject::new();
337 put_metadata(&mut custom, event.tags, event.properties, &mut log);
338
339 let shared = agentcat.inner();
340 if !shared.options.tracing() {
341 hand_over(agentcat, log, None, &session);
342 return Ok(());
343 }
344
345 let duration = event
346 .duration
347 .filter(|given| !given.is_zero())
348 .unwrap_or_else(|| published.duration_since(created).unwrap_or_default());
349 let text = |value: &str| Value::String(value.to_owned());
350 let mut object = EventObject::new();
351 object.insert("id".to_owned(), text(""));
352 object.insert("sessionId".to_owned(), Value::String(session.clone()));
353 object.insert("projectId".to_owned(), text(&shared.project_id));
354 object.insert("eventType".to_owned(), text(CUSTOM_EVENT_TYPE));
355 object.insert(
356 "timestamp".to_owned(),
357 Value::String(wire::iso_millis(created)),
358 );
359 object.insert("duration".to_owned(), millis(duration));
360 object.insert("sdkLanguage".to_owned(), text(SDK_LANGUAGE));
361 object.insert(
362 "agentcatVersion".to_owned(),
363 text(env!("CARGO_PKG_VERSION")),
364 );
365 put(
366 &mut object,
367 "serverName",
368 server.map(|info| text(&info.name)),
369 );
370 put(
371 &mut object,
372 "serverVersion",
373 server.map(|info| text(&info.version)),
374 );
375 put(
376 &mut object,
377 "clientName",
378 client.and_then(|client| client.name.as_deref()).map(text),
379 );
380 put(
381 &mut object,
382 "clientVersion",
383 client
384 .and_then(|client| client.version.as_deref())
385 .map(text),
386 );
387 object.insert("identifyActorData".to_owned(), Value::Object(Map::new()));
388 put_payload(&mut object, event.payload);
389 put(&mut object, "isError", event.is_error.map(Value::Bool));
390 put(&mut object, "error", event.error);
391 object.extend(custom);
392
393 let job = EventJob {
394 event: object,
395 hooks: PendingHooks::default(),
396 hook_mode: false,
397 runtime: Handle::try_current().ok(),
398 string_redaction: false,
399 event_redaction: true,
400 backtrace: None,
401 };
402 hand_over(agentcat, log, Some(job), &session);
403 Ok(())
404}