chat_engine_sdk/plugin.rs
1use std::time::{Duration, Instant};
2
3use async_trait::async_trait;
4use futures::stream::{self, BoxStream, StreamExt};
5use tokio_util::sync::CancellationToken;
6use uuid::Uuid;
7
8use crate::error::PluginError;
9use crate::models::{
10 Capability, CapabilityValue, HealthStatus, Message, StreamingEvent, TenantId, UserId,
11};
12
13/// A boxed async stream of streaming events from a plugin.
14///
15/// Each item is a `Result`, so individual events can fail (e.g., mid-stream
16/// network error) without aborting the stream. The outer `Result<PluginStream, _>`
17/// returned by the trait methods represents errors that occur *before* the stream
18/// starts (e.g., invalid config, plugin unavailable).
19///
20/// # `'static` lifetime requirement
21///
22/// `PluginStream` is `BoxStream<'static, _>`, meaning the stream must own
23/// everything it touches. Chat Engine drives the stream to completion *after*
24/// the trait method returns, so any reference into `&self` would dangle once
25/// the call frame unwinds.
26///
27/// The compiler error you will see if you violate this is a lifetime mismatch
28/// pointing at the inside of your `async_stream::stream! { … }`,
29/// `futures::stream::unfold(…)`, or async closure — *not* at the trait
30/// signature. The fix is to detach from `&self` before entering the stream body:
31///
32/// ```ignore
33/// // ❌ Captures `&self.config` — won't satisfy `'static`.
34/// async fn on_message(&self, ctx: MessagePluginCtx)
35/// -> Result<PluginStream, PluginError>
36/// {
37/// Ok(async_stream::stream! {
38/// let response = self.config.client.send(&ctx.messages).await?;
39/// // ...
40/// }.boxed())
41/// }
42///
43/// // ✅ Clone the bits you need out of `self` first.
44/// async fn on_message(&self, ctx: MessagePluginCtx)
45/// -> Result<PluginStream, PluginError>
46/// {
47/// let client = self.config.client.clone();
48/// Ok(async_stream::stream! {
49/// let response = client.send(&ctx.messages).await?;
50/// // ...
51/// }.boxed())
52/// }
53///
54/// // ✅ Or hold the plugin in an `Arc` and clone the handle.
55/// // (works well if you need many fields and `Clone` on each is awkward)
56/// // self: Arc<MyPlugin> at the call site, then:
57/// let me = Arc::clone(&self);
58/// Ok(async_stream::stream! {
59/// me.do_things(...).await;
60/// }.boxed())
61/// ```
62///
63/// For non-streaming responses, prefer [`stream_from_events`] — it side-steps
64/// the issue entirely by collecting all events synchronously before returning.
65pub type PluginStream = BoxStream<'static, Result<StreamingEvent, PluginError>>;
66
67/// Helper to build an empty plugin stream (default no-op responses).
68#[must_use]
69pub fn empty_stream() -> PluginStream {
70 stream::empty().boxed()
71}
72
73/// Helper to build a plugin stream from a pre-collected vector of events.
74///
75/// Useful for non-streaming plugins or stub implementations that produce all
76/// events up-front.
77#[must_use]
78pub fn stream_from_events(events: Vec<StreamingEvent>) -> PluginStream {
79 stream::iter(events.into_iter().map(Ok)).boxed()
80}
81
82#[allow(clippy::module_name_repetitions)]
83#[derive(Debug, Clone)]
84pub struct SessionPluginCtx {
85 pub session_type_id: Uuid,
86 /// `None` during [`ChatEngineBackendPlugin::on_session_type_configured`]
87 /// (no session exists yet); `Some` for all other lifecycle hooks
88 /// (`on_session_created`, `on_session_updated`, `on_session_summary`).
89 pub session_id: Option<Uuid>,
90 pub call_ctx: PluginCallContext,
91}
92
93/// Context passed to message-handling plugin methods.
94///
95/// `Debug` is implemented manually to redact `messages` — message content is
96/// PII (user prompts, assistant responses) that must never appear in logs.
97/// The summary surfaces `len` and a per-role count so observability is
98/// preserved without leaking text. `call_ctx` keeps its own redaction (see
99/// `PluginCallContext::Debug`).
100#[allow(clippy::module_name_repetitions)]
101#[derive(Clone)]
102pub struct MessagePluginCtx {
103 pub session_id: Uuid,
104 pub message_id: Uuid,
105 pub messages: Vec<Message>,
106 pub call_ctx: PluginCallContext,
107}
108
109impl std::fmt::Debug for MessagePluginCtx {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 // Per-role summary: counts how many user / assistant / system messages
112 // are present so plugins/operators can sanity-check the call shape
113 // without seeing any message content.
114 let mut user = 0_usize;
115 let mut assistant = 0_usize;
116 let mut system = 0_usize;
117 for m in &self.messages {
118 match m.role {
119 crate::models::MessageRole::User => user += 1,
120 crate::models::MessageRole::Assistant => assistant += 1,
121 crate::models::MessageRole::System => system += 1,
122 }
123 }
124 let messages_summary = format_args!(
125 "<redacted: {} message(s); user={user}, assistant={assistant}, system={system}>",
126 self.messages.len()
127 )
128 .to_string();
129 f.debug_struct("MessagePluginCtx")
130 .field("session_id", &self.session_id)
131 .field("message_id", &self.message_id)
132 .field("messages", &messages_summary)
133 .field("call_ctx", &self.call_ctx)
134 .finish()
135 }
136}
137
138/// Shared context attached to every plugin invocation.
139///
140/// `Debug` is implemented manually to redact `plugin_config` — it may contain
141/// secrets (API keys, webhook auth, credentials) that must never hit logs.
142/// Wrappers `SessionPluginCtx` / `MessagePluginCtx` derive `Debug` and
143/// transitively inherit this redaction.
144#[allow(clippy::module_name_repetitions)]
145#[derive(Clone)]
146pub struct PluginCallContext {
147 /// Correlation ID for this plugin invocation. Used for log correlation and
148 /// distributed tracing; Chat Engine generates a fresh UUIDv4 per call (or
149 /// may propagate an upstream correlation ID). Plugins should include this
150 /// in every log line emitted while handling the call.
151 pub request_id: Uuid,
152 /// Tenant that owns the session issuing the call.
153 pub tenant_id: TenantId,
154 /// End-user behind the call (opaque string from the auth token).
155 pub user_id: UserId,
156 /// GTS plugin instance ID that is handling the call (matches the bound
157 /// `SessionType.plugin_instance_id`).
158 pub plugin_instance_id: String,
159 /// Session type the call is scoped to.
160 pub session_type_id: Uuid,
161 /// Opaque plugin-specific configuration loaded from `plugin_configs` for
162 /// this `(plugin_instance_id, session_type_id)` pair.
163 pub plugin_config: Option<serde_json::Value>,
164 /// Capability values selected for this call (subset of those declared by
165 /// the plugin via `Capability`).
166 pub enabled_capabilities: Option<Vec<CapabilityValue>>,
167 /// Absolute monotonic deadline for this plugin call. Plugins should bound
168 /// long-running work (HTTP requests, retries) to remain within this budget.
169 /// `None` means Chat Engine did not set a deadline.
170 ///
171 /// Use `remaining()` for a convenient countdown duration.
172 pub deadline: Option<Instant>,
173 /// Cooperative cancellation signal. Cancelled by Chat Engine when:
174 /// - the client disconnects (HTTP stream closed)
175 /// - the deadline elapses (Chat Engine bridges deadline → cancel)
176 /// - explicit `DELETE /streaming` is invoked on a session
177 ///
178 /// Plugins should `select!` on `cancel.cancelled()` alongside their work
179 /// and return `PluginError::Transient("cancelled")` (or similar) when
180 /// the signal fires. `cancel.is_cancelled()` is also available for
181 /// pre-flight checks before expensive operations.
182 ///
183 /// Clones of this token share the same cancellation state. When Chat Engine
184 /// cancels, all clones observe the signal simultaneously — and conversely,
185 /// calling `.cancel()` on any clone (including one obtained by cloning the
186 /// enclosing `PluginCallContext`) cancels every other holder, including
187 /// Chat Engine's parent token. If you fan out concurrent sub-tasks that
188 /// need *independent* cancellation, derive child tokens with
189 /// [`CancellationToken::child_token`] rather than cloning.
190 pub cancel: CancellationToken,
191}
192
193impl PluginCallContext {
194 /// True if cancellation has been signalled.
195 #[must_use]
196 pub fn is_cancelled(&self) -> bool {
197 self.cancel.is_cancelled()
198 }
199
200 /// Remaining time until the deadline.
201 ///
202 /// - `None` — no deadline was set; the plugin may use its own default budget.
203 /// - `Some(Duration::ZERO)` — deadline has already elapsed; the plugin
204 /// should abort immediately (typically returning `PluginError::timeout()`).
205 /// - `Some(d)` where `d > 0` — `d` of budget remains. Plugins typically
206 /// pass this to `tokio::time::timeout(...)` or `reqwest::Client::timeout(...)`.
207 ///
208 /// **Important**: collapsing "no deadline" and "elapsed" into `None`
209 /// would be a footgun (`.unwrap_or(default)` would let elapsed deadlines
210 /// silently extend their budget). Callers must handle the three cases
211 /// distinctly.
212 #[must_use]
213 pub fn remaining(&self) -> Option<Duration> {
214 self.deadline.map(|d| {
215 d.checked_duration_since(Instant::now())
216 .unwrap_or(Duration::ZERO)
217 })
218 }
219}
220
221impl std::fmt::Debug for PluginCallContext {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 // `plugin_config` is redacted because it can carry plugin secrets
224 // (API keys, webhook auth, credentials) that must never appear in logs.
225 // We still indicate presence/absence so observability is not lost.
226 let plugin_config_redacted: Option<&'static str> =
227 self.plugin_config.as_ref().map(|_| "<redacted>");
228 f.debug_struct("PluginCallContext")
229 .field("request_id", &self.request_id)
230 .field("tenant_id", &self.tenant_id)
231 .field("user_id", &self.user_id)
232 .field("plugin_instance_id", &self.plugin_instance_id)
233 .field("session_type_id", &self.session_type_id)
234 .field("plugin_config", &plugin_config_redacted)
235 .field("enabled_capabilities", &self.enabled_capabilities)
236 .field("remaining", &self.remaining())
237 .field("cancelled", &self.is_cancelled())
238 .finish()
239 }
240}
241
242#[cfg(test)]
243mod plugin_call_context_tests {
244 use super::{CancellationToken, Duration, Instant, PluginCallContext, TenantId, UserId};
245 use uuid::Uuid;
246
247 fn make_ctx() -> PluginCallContext {
248 PluginCallContext {
249 request_id: Uuid::nil(),
250 tenant_id: TenantId::new("t"),
251 user_id: UserId::new("u"),
252 plugin_instance_id: "p".into(),
253 session_type_id: Uuid::nil(),
254 plugin_config: None,
255 enabled_capabilities: None,
256 deadline: None,
257 cancel: CancellationToken::new(),
258 }
259 }
260
261 #[test]
262 fn debug_redacts_plugin_config_when_present() {
263 let mut ctx = make_ctx();
264 ctx.plugin_config = Some(serde_json::json!({"api_key": "super-secret-123"}));
265 let printed = format!("{ctx:?}");
266 assert!(printed.contains("<redacted>"), "got: {printed}");
267 assert!(
268 !printed.contains("super-secret-123"),
269 "secret leaked: {printed}"
270 );
271 }
272
273 #[test]
274 fn debug_prints_none_when_plugin_config_absent() {
275 let ctx = make_ctx();
276 let printed = format!("{ctx:?}");
277 assert!(printed.contains("plugin_config: None"), "got: {printed}");
278 assert!(!printed.contains("<redacted>"), "got: {printed}");
279 }
280
281 #[test]
282 fn is_cancelled_reflects_token_state() {
283 let ctx = make_ctx();
284 assert!(!ctx.is_cancelled());
285 ctx.cancel.cancel();
286 assert!(ctx.is_cancelled());
287 }
288
289 #[test]
290 fn remaining_is_none_when_no_deadline() {
291 let ctx = make_ctx();
292 assert!(ctx.remaining().is_none());
293 }
294
295 #[test]
296 fn remaining_returns_positive_duration_for_future_deadline() {
297 let mut ctx = make_ctx();
298 ctx.deadline = Some(Instant::now() + Duration::from_secs(10));
299 let r = ctx.remaining().expect("should be set");
300 assert!(r > Duration::from_secs(5) && r <= Duration::from_secs(10));
301 }
302
303 #[test]
304 fn remaining_is_zero_when_deadline_already_elapsed() {
305 let mut ctx = make_ctx();
306 ctx.deadline = Some(
307 Instant::now()
308 .checked_sub(Duration::from_secs(1))
309 .expect("monotonic clock is at least 1s past its reference"),
310 );
311 // Elapsed deadlines must be Some(ZERO), not None — the latter would
312 // be indistinguishable from "no deadline set" and let plugins
313 // silently extend their budget via `.unwrap_or(default)`.
314 assert_eq!(ctx.remaining(), Some(Duration::ZERO));
315 }
316
317 #[test]
318 fn remaining_is_zero_at_exact_deadline() {
319 let mut ctx = make_ctx();
320 ctx.deadline = Some(Instant::now());
321 // Race-tolerant: anything <= 0 is Some(ZERO); a tiny positive value
322 // is also acceptable but should be sub-millisecond.
323 let r = ctx.remaining().expect("deadline is set");
324 assert!(r <= Duration::from_millis(1), "expected ~ZERO, got {r:?}");
325 }
326}
327
328#[cfg(test)]
329mod message_plugin_ctx_debug_tests {
330 use super::{CancellationToken, MessagePluginCtx, PluginCallContext, TenantId, UserId};
331 use crate::models::{Message, MessageRole};
332 use time::OffsetDateTime;
333 use uuid::Uuid;
334
335 fn make_message(role: MessageRole, secret_text: &str) -> Message {
336 let now = OffsetDateTime::now_utc();
337 Message {
338 message_id: Uuid::nil(),
339 session_id: Uuid::nil(),
340 tenant_id: None,
341 user_id: None,
342 parent_message_id: None,
343 variant_index: 0,
344 is_active: true,
345 role,
346 parts: vec![crate::models::MessagePart::text(
347 Uuid::nil(),
348 Uuid::nil(),
349 0,
350 secret_text,
351 )],
352 file_ids: vec![],
353 metadata: None,
354 is_complete: true,
355 is_hidden_from_user: false,
356 is_hidden_from_backend: false,
357 created_at: now,
358 updated_at: now,
359 }
360 }
361
362 fn make_call_ctx() -> PluginCallContext {
363 PluginCallContext {
364 request_id: Uuid::nil(),
365 tenant_id: TenantId::new("t"),
366 user_id: UserId::new("u"),
367 plugin_instance_id: "p".into(),
368 session_type_id: Uuid::nil(),
369 plugin_config: None,
370 enabled_capabilities: None,
371 deadline: None,
372 cancel: CancellationToken::new(),
373 }
374 }
375
376 #[test]
377 fn debug_redacts_message_content_but_shows_per_role_summary() {
378 let ctx = MessagePluginCtx {
379 session_id: Uuid::nil(),
380 message_id: Uuid::nil(),
381 messages: vec![
382 make_message(MessageRole::System, "system-secret-prompt"),
383 make_message(MessageRole::User, "i had a heart attack last night"),
384 make_message(MessageRole::Assistant, "private-response-PII"),
385 make_message(MessageRole::User, "another sensitive question"),
386 ],
387 call_ctx: make_call_ctx(),
388 };
389 let printed = format!("{ctx:?}");
390
391 // PII never appears in Debug output.
392 assert!(
393 !printed.contains("system-secret-prompt"),
394 "system content leaked: {printed}"
395 );
396 assert!(
397 !printed.contains("heart attack"),
398 "user PII leaked: {printed}"
399 );
400 assert!(
401 !printed.contains("private-response-PII"),
402 "assistant content leaked: {printed}"
403 );
404 assert!(
405 !printed.contains("sensitive question"),
406 "user content leaked: {printed}"
407 );
408
409 // Summary still gives observability.
410 assert!(printed.contains("4 message(s)"), "got: {printed}");
411 assert!(printed.contains("user=2"), "got: {printed}");
412 assert!(printed.contains("assistant=1"), "got: {printed}");
413 assert!(printed.contains("system=1"), "got: {printed}");
414 assert!(printed.contains("<redacted"), "got: {printed}");
415 }
416
417 #[test]
418 fn debug_shows_zero_counts_for_empty_history() {
419 let ctx = MessagePluginCtx {
420 session_id: Uuid::nil(),
421 message_id: Uuid::nil(),
422 messages: vec![],
423 call_ctx: make_call_ctx(),
424 };
425 let printed = format!("{ctx:?}");
426 assert!(printed.contains("0 message(s)"), "got: {printed}");
427 assert!(printed.contains("user=0"), "got: {printed}");
428 }
429}
430
431/// What a session lifecycle hook returns: the capability set plus optional
432/// metadata the plugin wants persisted onto the session.
433///
434/// Returned by [`ChatEngineBackendPlugin::on_session_created`],
435/// [`on_session_updated`](ChatEngineBackendPlugin::on_session_updated), and
436/// [`on_session_type_configured`](ChatEngineBackendPlugin::on_session_type_configured).
437/// For the session-bound hooks (`on_session_created` / `on_session_updated`)
438/// Chat Engine merges `metadata` into `Session.metadata`. For
439/// `on_session_type_configured` there is no session, so `metadata` is ignored.
440#[derive(Debug, Clone, Default)]
441pub struct SessionPluginResponse {
442 /// Capabilities to expose — stored as `Session.enabled_capabilities`
443 /// (session hooks) or `SessionType.available_capabilities` (type hook).
444 pub capabilities: Vec<Capability>,
445 /// Optional metadata merged into the owning session's `metadata` (object
446 /// merge; plugin keys override existing same-name keys). `None` leaves the
447 /// session metadata untouched.
448 pub metadata: Option<serde_json::Value>,
449}
450
451impl From<Vec<Capability>> for SessionPluginResponse {
452 /// Ergonomic conversion for plugins that only resolve capabilities.
453 fn from(capabilities: Vec<Capability>) -> Self {
454 Self {
455 capabilities,
456 metadata: None,
457 }
458 }
459}
460
461#[async_trait]
462pub trait ChatEngineBackendPlugin: Send + Sync {
463 async fn on_session_type_configured(
464 &self,
465 _ctx: SessionPluginCtx,
466 ) -> Result<SessionPluginResponse, PluginError> {
467 Ok(SessionPluginResponse::default())
468 }
469
470 async fn on_session_created(
471 &self,
472 _ctx: SessionPluginCtx,
473 ) -> Result<SessionPluginResponse, PluginError> {
474 Ok(SessionPluginResponse::default())
475 }
476
477 async fn on_session_updated(
478 &self,
479 _ctx: SessionPluginCtx,
480 ) -> Result<SessionPluginResponse, PluginError> {
481 Ok(SessionPluginResponse::default())
482 }
483
484 /// Process a new user message and stream response events back.
485 ///
486 /// The outer `Result` reports failures *before* streaming starts (e.g., auth
487 /// failure). Once a stream is returned, individual items may be `Err` to
488 /// signal mid-stream failures (e.g., upstream disconnect).
489 ///
490 /// The returned [`PluginStream`] must be `'static` — it cannot borrow from
491 /// `&self`. See [`PluginStream`]'s docs for the idiomatic way to detach
492 /// captured state (clone fields out, or hold `self` in an `Arc`).
493 async fn on_message(&self, _ctx: MessagePluginCtx) -> Result<PluginStream, PluginError> {
494 Ok(empty_stream())
495 }
496
497 /// Regenerate a response for an existing user message (new variant).
498 ///
499 /// Same streaming semantics as `on_message`.
500 async fn on_message_recreate(
501 &self,
502 _ctx: MessagePluginCtx,
503 ) -> Result<PluginStream, PluginError> {
504 Ok(empty_stream())
505 }
506
507 /// Generate a session summary and stream the result back.
508 ///
509 /// Summary plugins typically emit one or more `Chunk` events followed by a
510 /// `Complete` event carrying metadata.
511 async fn on_session_summary(
512 &self,
513 _ctx: SessionPluginCtx,
514 ) -> Result<PluginStream, PluginError> {
515 Ok(empty_stream())
516 }
517
518 async fn health_check(&self) -> Result<HealthStatus, PluginError> {
519 Ok(HealthStatus::Healthy)
520 }
521
522 fn plugin_instance_id(&self) -> &str;
523}