smooai-smooth-operator 1.2.0

Reference core for smooth-operator: domain model + StorageAdapter seam over smooth-operator.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! Domain model for smooth-operator.
//!
//! These structs mirror `spec/domain/*.json` exactly (field names, optionality)
//! and are storage-agnostic — no backend is named here. They are the shapes the
//! `StorageAdapter` (see [`crate::adapter`]) reads and writes.
//!
//! Checkpoints are *not* redefined here: we re-use smooth-operator's
//! [`Checkpoint`](smooth_operator_core::Checkpoint) directly so the engine plugs
//! straight into the checkpoint slice. See [`crate::adapter`].

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

// Re-export the engine's Checkpoint so callers get the domain "Checkpoint"
// from one place. spec/domain/checkpoint.schema.json documents this struct
// as "the `Checkpoint` struct in the smooth-operator Rust crate".
pub use smooth_operator_core::Checkpoint;

/// The channel on which a conversation takes place.
/// Mirrors `conversation.schema.json#/properties/platform`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Platform {
    Web,
    Messenger,
    Instagram,
    Email,
    Discord,
    Phone,
    Sms,
    Slack,
    Whatsapp,
    Tiktok,
}

/// A conversation thread between participants.
/// Mirrors `conversation.schema.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conversation {
    pub id: String,
    pub platform: Platform,
    pub name: String,
    pub organization_id: String,
    pub idempotency_key: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata_json: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub analytics_json: Option<Value>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Participant role discriminator.
/// Mirrors `participant.schema.json#/properties/type` (`user` | `ai-agent` | `human-agent`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ParticipantType {
    User,
    AiAgent,
    HumanAgent,
}

/// A participant in a conversation.
/// Mirrors `participant.schema.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Participant {
    pub id: String,
    pub conversation_id: String,
    pub organization_id: String,
    #[serde(rename = "type")]
    pub participant_type: ParticipantType,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub external_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub internal_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub browser_fingerprint: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub browser_info: Option<Value>,
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub crm_contact_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata_json: Option<Value>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Message direction relative to the platform.
/// Mirrors `message.schema.json#/properties/direction`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
    Inbound,
    Outbound,
}

/// A single content element within a message.
/// Mirrors `message.schema.json#/$defs/ContentItem`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentItem {
    /// Content item type discriminator. Currently only `"text"` is defined.
    #[serde(rename = "type")]
    pub item_type: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
}

impl ContentItem {
    /// Build a `text` content item.
    pub fn text(text: impl Into<String>) -> Self {
        Self {
            item_type: "text".to_string(),
            text: Some(text.into()),
        }
    }
}

/// Structured content of a message.
/// Mirrors `message.schema.json#/$defs/MessageContent`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageContent {
    #[serde(default)]
    pub items: Vec<ContentItem>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub structured_response: Option<Value>,
}

impl MessageContent {
    /// Convenience: a single text item plus the flat-text mirror.
    pub fn from_text(text: impl Into<String>) -> Self {
        let text = text.into();
        Self {
            items: vec![ContentItem::text(text.clone())],
            text: Some(text),
            structured_response: None,
        }
    }
}

/// Abbreviated sender/recipient descriptor (wire shape).
/// Mirrors `message.schema.json#/properties/from` (and `to`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParticipantRef {
    pub id: String,
    #[serde(rename = "type")]
    pub participant_type: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

/// A single message within a conversation.
/// Mirrors `message.schema.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Message {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub external_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub organization_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    pub direction: Direction,
    pub content: MessageContent,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub from: Option<ParticipantRef>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub to: Option<ParticipantRef>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata_json: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub analytics_json: Option<Value>,
    pub created_at: DateTime<Utc>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<DateTime<Utc>>,
}

/// Lifecycle status of a session.
/// Mirrors `session.schema.json#/properties/status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionStatus {
    Active,
    Idle,
    Ended,
}

/// An AI conversation session — ties a conversation to a smooth-operator
/// workflow thread via `thread_id`.
/// Mirrors `session.schema.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
    pub session_id: String,
    pub conversation_id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub user_participant_id: String,
    pub agent_participant_id: String,
    /// smooth-operator workflow thread identifier (the historical
    /// `langgraph_thread_id`). Resumes agent state across turns.
    pub thread_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<SessionStatus>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token_count: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message_count: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<HashMap<String, Value>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub created_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ended_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_activity_at: Option<DateTime<Utc>>,
}

/// A source the agent used to ground its answer.
///
/// Mirrors `spec/domain/citation.schema.json` and the optional `citations`
/// array on the terminal `eventual_response` event. Each citation points back at
/// one retrieved knowledge-base document — the chunk the model read plus enough
/// metadata to render an attribution link.
///
/// Citations are built from the
/// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult)s that actually
/// grounded a turn (see [`Citation::from_knowledge_result`] /
/// [`From<KnowledgeResult>`]): `id` ← `document_id`, `title` ← `source`,
/// `url` ← `source` when it is an `http(s)` URL (the GitHub blob/issue URL the
/// connector stamps onto the document's `source` at ingest — see
/// `docs/CONNECTORS.md`) else `None`, `snippet` ← the chunk truncated for
/// display, `score` ← `score`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Citation {
    /// Stable identifier of the cited source document (the knowledge-base
    /// `document_id`). Used to deduplicate citations within a turn.
    pub id: String,
    /// Human-readable label for the source — the document's source path or, for
    /// web-sourced docs, the URL/title.
    pub title: String,
    /// Canonical link to the source, when one exists. For GitHub-sourced
    /// documents this is the blob/issue URL stamped onto the document's `source`
    /// at ingest. `None` for sources with no web location (e.g. uploaded files).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// The retrieved chunk text that grounded the answer, truncated for display.
    pub snippet: String,
    /// Relevance score of this source for the turn's query (the knowledge-base
    /// similarity score). Higher is more relevant.
    pub score: f32,
}

/// Max characters of a chunk to carry as a citation `snippet`. Bounds the size
/// of the `eventual_response` payload; the full chunk lives in the KB.
pub const CITATION_SNIPPET_MAX_CHARS: usize = 280;

impl Citation {
    /// Build a [`Citation`] from a knowledge-base
    /// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult).
    ///
    /// - `id` ← `document_id`
    /// - `title` ← `source`
    /// - `url` ← `source` when it parses as an `http`/`https` URL (the GitHub
    ///   blob/issue URL the connector stamps onto `Document.source` at ingest —
    ///   `docs/CONNECTORS.md`), otherwise `None` (e.g. a local `policies/x.md`
    ///   path has no web location).
    /// - `snippet` ← `chunk`, truncated to [`CITATION_SNIPPET_MAX_CHARS`] on a
    ///   char boundary (an ellipsis appended when truncated).
    /// - `score` ← `score`
    #[must_use]
    pub fn from_knowledge_result(result: &smooth_operator_core::KnowledgeResult) -> Self {
        Self {
            id: result.document_id.clone(),
            title: result.source.clone(),
            url: web_url(&result.source),
            snippet: truncate_snippet(&result.chunk, CITATION_SNIPPET_MAX_CHARS),
            score: result.score,
        }
    }
}

impl From<&smooth_operator_core::KnowledgeResult> for Citation {
    fn from(result: &smooth_operator_core::KnowledgeResult) -> Self {
        Self::from_knowledge_result(result)
    }
}

impl From<smooth_operator_core::KnowledgeResult> for Citation {
    fn from(result: smooth_operator_core::KnowledgeResult) -> Self {
        Self::from_knowledge_result(&result)
    }
}

/// Return `Some(source)` when `source` is an `http`/`https` URL (the citation's
/// `url`), else `None`. GitHub-sourced documents carry the blob/issue URL in
/// `Document.source`; local docs carry a path, which has no web location.
fn web_url(source: &str) -> Option<String> {
    if source.starts_with("http://") || source.starts_with("https://") {
        Some(source.to_string())
    } else {
        None
    }
}

/// Truncate `text` to at most `max` chars on a char boundary, appending `…`
/// when truncation occurred. Empty/short text is returned unchanged.
fn truncate_snippet(text: &str, max: usize) -> String {
    if text.chars().count() <= max {
        return text.to_string();
    }
    let mut out: String = text.chars().take(max).collect();
    out.push('');
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn ts() -> DateTime<Utc> {
        DateTime::parse_from_rfc3339("2026-06-07T12:00:00Z")
            .unwrap()
            .with_timezone(&Utc)
    }

    #[test]
    fn participant_serializes_camelcase_and_kebab_type() {
        let p = Participant {
            id: "p1".into(),
            conversation_id: "c1".into(),
            organization_id: "org1".into(),
            participant_type: ParticipantType::AiAgent,
            external_id: None,
            internal_id: Some("agent-uuid".into()),
            browser_fingerprint: None,
            browser_info: None,
            name: "Smantha".into(),
            email: None,
            phone: None,
            crm_contact_id: None,
            metadata_json: None,
            created_at: ts(),
            updated_at: ts(),
        };
        let v = serde_json::to_value(&p).unwrap();
        // camelCase field names match the spec
        assert!(v.get("conversationId").is_some());
        assert!(v.get("organizationId").is_some());
        assert!(v.get("internalId").is_some());
        // `type` discriminator is kebab-cased per the enum spec
        assert_eq!(v.get("type").unwrap(), &json!("ai-agent"));
        // round-trip
        let back: Participant = serde_json::from_value(v).unwrap();
        assert_eq!(back.participant_type, ParticipantType::AiAgent);
    }

    #[test]
    fn participant_type_variants_match_spec() {
        assert_eq!(
            serde_json::to_value(ParticipantType::User).unwrap(),
            json!("user")
        );
        assert_eq!(
            serde_json::to_value(ParticipantType::AiAgent).unwrap(),
            json!("ai-agent")
        );
        assert_eq!(
            serde_json::to_value(ParticipantType::HumanAgent).unwrap(),
            json!("human-agent")
        );
    }

    #[test]
    fn message_serializes_direction_and_content_items() {
        let m = Message {
            id: "m1".into(),
            external_id: None,
            organization_id: Some("org1".into()),
            conversation_id: Some("c1".into()),
            direction: Direction::Inbound,
            content: MessageContent::from_text("hello"),
            from: Some(ParticipantRef {
                id: "p1".into(),
                participant_type: "user".into(),
                name: Some("Visitor".into()),
            }),
            to: None,
            metadata_json: None,
            analytics_json: None,
            created_at: ts(),
            updated_at: None,
        };
        let v = serde_json::to_value(&m).unwrap();
        assert_eq!(v.get("direction").unwrap(), &json!("inbound"));
        assert_eq!(v["content"]["items"][0]["type"], json!("text"));
        assert_eq!(v["content"]["items"][0]["text"], json!("hello"));
        assert_eq!(v["content"]["text"], json!("hello"));
        // `from` uses camelCase `id`/`type`
        assert_eq!(v["from"]["type"], json!("user"));
        let back: Message = serde_json::from_value(v).unwrap();
        assert_eq!(back.direction, Direction::Inbound);
    }

    #[test]
    fn session_uses_thread_id_camelcase() {
        let s = Session {
            session_id: "s1".into(),
            conversation_id: "c1".into(),
            agent_id: "a1".into(),
            agent_name: "Smantha".into(),
            user_participant_id: "pu".into(),
            agent_participant_id: "pa".into(),
            thread_id: "thread-xyz".into(),
            status: Some(SessionStatus::Active),
            token_count: Some(0),
            message_count: Some(0),
            metadata: None,
            created_at: Some(ts()),
            updated_at: Some(ts()),
            ended_at: None,
            last_activity_at: Some(ts()),
        };
        let v = serde_json::to_value(&s).unwrap();
        assert!(v.get("sessionId").is_some());
        assert!(v.get("conversationId").is_some());
        assert!(v.get("userParticipantId").is_some());
        assert!(v.get("agentParticipantId").is_some());
        assert_eq!(v.get("threadId").unwrap(), &json!("thread-xyz"));
        assert_eq!(v.get("status").unwrap(), &json!("active"));
        let back: Session = serde_json::from_value(v).unwrap();
        assert_eq!(back.thread_id, "thread-xyz");
        assert_eq!(back.status, Some(SessionStatus::Active));
    }

    #[test]
    fn conversation_platform_and_camelcase() {
        let c = Conversation {
            id: "c1".into(),
            platform: Platform::Web,
            name: "Lead chat".into(),
            organization_id: "org1".into(),
            idempotency_key: "idem-1".into(),
            metadata_json: Some(json!({"campaign": "spring"})),
            analytics_json: None,
            created_at: ts(),
            updated_at: ts(),
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v.get("platform").unwrap(), &json!("web"));
        assert!(v.get("organizationId").is_some());
        assert!(v.get("idempotencyKey").is_some());
        assert_eq!(v["metadataJson"]["campaign"], json!("spring"));
        let back: Conversation = serde_json::from_value(v).unwrap();
        assert_eq!(back.platform, Platform::Web);
    }
}