use std::time::{Duration, SystemTime};
use rmcp::model::Implementation;
use serde_json::{Map, Value};
use tokio::runtime::Handle;
use crate::client::AgentCat;
use crate::error::Error;
use crate::event::ClientIdentity;
use crate::event::assemble::{EventJob, SDK_LANGUAGE};
use crate::hooks::PendingHooks;
use crate::pipeline::EventObject;
use crate::pipeline::tags::validate_tags;
use crate::wire;
#[cfg(test)]
mod tests;
#[derive(Clone, Debug, Default)]
pub struct CustomEvent {
pub(crate) session_id: Option<String>,
pub(crate) resource_name: Option<String>,
pub(crate) parameters: Option<Value>,
pub(crate) response: Option<Value>,
pub(crate) message: Option<String>,
pub(crate) duration: Option<Duration>,
pub(crate) is_error: Option<bool>,
pub(crate) error: Option<Value>,
pub(crate) tags: Option<Map<String, Value>>,
pub(crate) properties: Option<Map<String, Value>>,
}
impl CustomEvent {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn session_id(mut self, id: impl Into<String>) -> Self {
self.session_id = Some(id.into());
self
}
#[must_use]
pub fn resource_name(mut self, name: impl Into<String>) -> Self {
self.resource_name = Some(name.into());
self
}
#[must_use]
pub fn parameters(mut self, parameters: Value) -> Self {
self.parameters = Some(parameters);
self
}
#[must_use]
pub fn response(mut self, response: Value) -> Self {
self.response = Some(response);
self
}
#[must_use]
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub fn is_error(mut self, is_error: bool) -> Self {
self.is_error = Some(is_error);
self
}
#[must_use]
pub fn error(mut self, error: Value) -> Self {
self.error = Some(error);
self
}
#[must_use]
pub fn tags(mut self, tags: Map<String, Value>) -> Self {
self.tags = Some(tags);
self
}
#[must_use]
pub fn properties(mut self, properties: Map<String, Value>) -> Self {
self.properties = Some(properties);
self
}
}
const CUSTOM_EVENT_TYPE: &str = "agentcat:custom";
const 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.";
fn put(event: &mut EventObject, key: &str, value: Option<Value>) {
if let Some(value) = value {
event.insert(key.to_owned(), value);
}
}
fn millis(duration: Duration) -> Value {
Value::from(u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
}
struct Payload {
resource_name: Option<String>,
parameters: Option<Value>,
response: Option<Value>,
message: Option<String>,
}
fn put_payload(event: &mut EventObject, payload: Payload) {
let Payload {
resource_name,
parameters,
response,
message,
} = payload;
put(event, "resourceName", resource_name.map(Value::String));
put(event, "parameters", parameters);
put(event, "response", response);
put(event, "userIntent", message.map(Value::String));
}
struct Accepted {
session_id: Option<String>,
payload: Payload,
duration: Option<Duration>,
is_error: Option<bool>,
error: Option<Value>,
tags: Option<Map<String, Value>>,
properties: Option<Map<String, Value>>,
}
fn accept(
agentcat: &AgentCat,
project_id: &str,
event: CustomEvent,
) -> Result<Option<Accepted>, Error> {
if !agentcat.inner().enabled {
return Ok(None);
}
if project_id.is_empty() {
return Err(Error::ProjectIdRequired);
}
let CustomEvent {
session_id,
resource_name,
parameters,
response,
message,
duration,
is_error,
error,
tags,
properties,
} = event;
Ok(Some(Accepted {
session_id: session_id.filter(|own| !own.is_empty()),
payload: Payload {
resource_name,
parameters,
response,
message,
},
duration,
is_error,
error,
tags,
properties,
}))
}
fn put_metadata(
event: &mut EventObject,
tags: Option<Map<String, Value>>,
properties: Option<Map<String, Value>>,
log: &mut Vec<String>,
) {
if let Some(tags) = tags {
let valid = validate_tags(&tags, log);
event.insert("tags".to_owned(), valid.map_or(Value::Null, Value::Object));
}
if let Some(properties) = properties.filter(|properties| !properties.is_empty()) {
event.insert("properties".to_owned(), Value::Object(properties));
}
}
fn hand_over(agentcat: &AgentCat, log: Vec<String>, job: Option<EventJob>, session_id: &str) {
let sink = &agentcat.inner().sink;
for line in log {
sink.log(line);
}
let whose = if session_id.is_empty() {
"without a session".to_owned()
} else {
format!("for session {session_id}")
};
let published = format!("Published custom event {whose} with type '{CUSTOM_EVENT_TYPE}'");
match job {
Some(job) => sink.event_then_log(job, published),
None => sink.log(published),
}
}
pub(crate) fn publish_string(
agentcat: &AgentCat,
session_id: &str,
project_id: &str,
event: CustomEvent,
now: SystemTime,
) -> Result<(), Error> {
let Some(event) = accept(agentcat, project_id, event)? else {
return Ok(());
};
let session = event.session_id.unwrap_or_else(|| session_id.to_owned());
let mut log = Vec::new();
let mut object = EventObject::new();
object.insert("sessionId".to_owned(), Value::String(session.clone()));
object.insert("projectId".to_owned(), Value::String(project_id.to_owned()));
object.insert("eventType".to_owned(), Value::from(CUSTOM_EVENT_TYPE));
object.insert("timestamp".to_owned(), Value::String(wire::iso_millis(now)));
put_payload(&mut object, event.payload);
put(&mut object, "duration", event.duration.map(millis));
put(&mut object, "isError", event.is_error.map(Value::Bool));
put(&mut object, "error", event.error);
put_metadata(&mut object, event.tags, event.properties, &mut log);
let job = EventJob {
event: object,
hooks: PendingHooks::default(),
hook_mode: false,
runtime: None,
string_redaction: false,
event_redaction: false,
backtrace: None,
};
hand_over(agentcat, log, Some(job), &session);
Ok(())
}
pub(crate) fn publish_server(
agentcat: &AgentCat,
project_id: &str,
event: CustomEvent,
client: Option<&ClientIdentity>,
server: Option<&Implementation>,
created: SystemTime,
published: SystemTime,
) -> Result<(), Error> {
let Some(event) = accept(agentcat, project_id, event)? else {
return Ok(());
};
let mut log = Vec::new();
let session = event.session_id.unwrap_or_else(|| {
log.push(NO_SESSION_WARNING.to_owned());
String::new()
});
let mut custom = EventObject::new();
put_metadata(&mut custom, event.tags, event.properties, &mut log);
let shared = agentcat.inner();
if !shared.options.tracing() {
hand_over(agentcat, log, None, &session);
return Ok(());
}
let duration = event
.duration
.filter(|given| !given.is_zero())
.unwrap_or_else(|| published.duration_since(created).unwrap_or_default());
let text = |value: &str| Value::String(value.to_owned());
let mut object = EventObject::new();
object.insert("id".to_owned(), text(""));
object.insert("sessionId".to_owned(), Value::String(session.clone()));
object.insert("projectId".to_owned(), text(&shared.project_id));
object.insert("eventType".to_owned(), text(CUSTOM_EVENT_TYPE));
object.insert(
"timestamp".to_owned(),
Value::String(wire::iso_millis(created)),
);
object.insert("duration".to_owned(), millis(duration));
object.insert("sdkLanguage".to_owned(), text(SDK_LANGUAGE));
object.insert(
"agentcatVersion".to_owned(),
text(env!("CARGO_PKG_VERSION")),
);
put(
&mut object,
"serverName",
server.map(|info| text(&info.name)),
);
put(
&mut object,
"serverVersion",
server.map(|info| text(&info.version)),
);
put(
&mut object,
"clientName",
client.and_then(|client| client.name.as_deref()).map(text),
);
put(
&mut object,
"clientVersion",
client
.and_then(|client| client.version.as_deref())
.map(text),
);
object.insert("identifyActorData".to_owned(), Value::Object(Map::new()));
put_payload(&mut object, event.payload);
put(&mut object, "isError", event.is_error.map(Value::Bool));
put(&mut object, "error", event.error);
object.extend(custom);
let job = EventJob {
event: object,
hooks: PendingHooks::default(),
hook_mode: false,
runtime: Handle::try_current().ok(),
string_redaction: false,
event_redaction: true,
backtrace: None,
};
hand_over(agentcat, log, Some(job), &session);
Ok(())
}