Skip to main content

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}