1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use uuid::Uuid;
7
8use crate::PROTOCOL_VERSION;
9use crate::adapter::Extracted;
10
11pub type ProviderOptions = BTreeMap<String, Value>;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Session {
15 pub id: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub parent_session_id: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
23 pub parent_message_id: Option<String>,
24 pub source_agent: String,
25 pub created_at: DateTime<Utc>,
26 pub project: Extracted<String>,
27 #[serde(default)]
28 pub options: ProviderOptions,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "role", rename_all = "snake_case")]
33pub enum Message {
34 System {
35 id: String,
36 session_id: String,
37 timestamp: DateTime<Utc>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
45 content: Option<Extracted<String>>,
46 #[serde(default)]
47 options: ProviderOptions,
48 },
49 User {
50 id: String,
51 session_id: String,
52 timestamp: DateTime<Utc>,
53 #[serde(default)]
54 options: ProviderOptions,
55 },
56 Assistant {
57 id: String,
58 session_id: String,
59 timestamp: DateTime<Utc>,
60 #[serde(default)]
61 options: ProviderOptions,
62 },
63 Tool {
64 id: String,
65 session_id: String,
66 timestamp: DateTime<Utc>,
67 #[serde(default)]
68 options: ProviderOptions,
69 },
70}
71
72impl Message {
73 pub fn id(&self) -> &str {
74 match self {
75 Self::System { id, .. }
76 | Self::User { id, .. }
77 | Self::Assistant { id, .. }
78 | Self::Tool { id, .. } => id,
79 }
80 }
81
82 pub fn session_id(&self) -> &str {
83 match self {
84 Self::System { session_id, .. }
85 | Self::User { session_id, .. }
86 | Self::Assistant { session_id, .. }
87 | Self::Tool { session_id, .. } => session_id,
88 }
89 }
90
91 pub fn role(&self) -> Role {
92 match self {
93 Self::System { .. } => Role::System,
94 Self::User { .. } => Role::User,
95 Self::Assistant { .. } => Role::Assistant,
96 Self::Tool { .. } => Role::Tool,
97 }
98 }
99
100 pub fn timestamp(&self) -> DateTime<Utc> {
101 match self {
102 Self::System { timestamp, .. }
103 | Self::User { timestamp, .. }
104 | Self::Assistant { timestamp, .. }
105 | Self::Tool { timestamp, .. } => *timestamp,
106 }
107 }
108
109 pub fn options(&self) -> &ProviderOptions {
110 match self {
111 Self::System { options, .. }
112 | Self::User { options, .. }
113 | Self::Assistant { options, .. }
114 | Self::Tool { options, .. } => options,
115 }
116 }
117
118 pub fn system_content(&self) -> Option<&str> {
119 match self {
120 Self::System { content, .. } => content.as_deref().map(|e| &**e),
124 Self::User { .. } | Self::Assistant { .. } | Self::Tool { .. } => None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum Role {
132 System,
133 User,
134 Assistant,
135 Tool,
136}
137
138impl Role {
139 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::System => "system",
142 Self::User => "user",
143 Self::Assistant => "assistant",
144 Self::Tool => "tool",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum Provenance {
156 Conversational,
157 Injected,
158}
159
160impl Provenance {
161 pub fn as_str(self) -> &'static str {
162 match self {
163 Self::Conversational => "conversational",
164 Self::Injected => "injected",
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct Part {
171 pub session_id: String,
172 pub id: String,
173 pub message_id: String,
174 pub ordinal: i32,
175 pub provenance: Provenance,
178 #[serde(default)]
179 pub options: ProviderOptions,
180 #[serde(flatten)]
181 pub kind: PartKind,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(tag = "type", rename_all = "snake_case")]
186pub enum PartKind {
187 Text {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
193 text: Option<Extracted<String>>,
194 },
195 Reasoning {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
201 text: Option<Extracted<String>>,
202 },
203 File {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
209 media_type: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 file_name: Option<String>,
212 data: FileData,
213 },
214 ToolCall {
215 #[serde(default, skip_serializing_if = "Option::is_none")]
219 call_id: Option<Extracted<String>>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
225 name: Option<Extracted<String>>,
226 params: Value,
227 provider_executed: bool,
228 },
229 ToolResult {
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 call_id: Option<Extracted<String>>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
240 name: Option<Extracted<String>>,
241 is_failure: bool,
242 result: Value,
243 },
244 ToolApprovalRequest {
245 approval_id: String,
246 tool_call_id: String,
247 },
248 ToolApprovalResponse {
249 approval_id: String,
250 approved: bool,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 reason: Option<String>,
253 },
254}
255
256impl PartKind {
257 pub fn type_name(&self) -> &'static str {
258 match self {
259 Self::Text { .. } => "text",
260 Self::Reasoning { .. } => "reasoning",
261 Self::File { .. } => "file",
262 Self::ToolCall { .. } => "tool_call",
263 Self::ToolResult { .. } => "tool_result",
264 Self::ToolApprovalRequest { .. } => "tool_approval_request",
265 Self::ToolApprovalResponse { .. } => "tool_approval_response",
266 }
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
272pub enum FileData {
273 String(String),
274 Bytes(Vec<u8>),
275 Url(String),
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum ErrorCode {
281 ValidationFailed,
282 VersionUnsupported,
283 NotFound,
284 NamespaceUnknown,
285 StorageUnavailable,
286 Conflict,
287 Internal,
288}
289
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct ErrorBody {
292 pub code: ErrorCode,
293 pub message: String,
294 #[serde(default)]
295 pub details: Value,
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct ErrorEnvelope {
300 pub error: ErrorBody,
301}
302
303#[allow(clippy::large_enum_variant)]
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308#[serde(untagged)]
309pub enum GetEnvelope {
310 Success(GetResponse),
311 Error(ErrorEnvelope),
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315pub struct GetRequest {
316 pub protocol_version: u16,
317 #[serde(default)]
318 pub namespace: Option<String>,
319 #[serde(default)]
321 pub session_id: Option<String>,
322 #[serde(default)]
323 pub message_id: Option<String>,
324 #[serde(default)]
328 pub context_depth: usize,
329 #[serde(default = "default_get_limit")]
330 pub limit: usize,
331 #[serde(default)]
334 pub response_mode: ResponseMode,
335 #[serde(default)]
339 pub after_id: Option<String>,
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ResponseMode {
347 #[default]
349 Conversational,
350 Complete,
352 Verbatim,
354}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361pub struct GetResponse {
362 pub session: GetSession,
363 #[serde(flatten)]
364 pub result: GetResult,
365}
366
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371pub struct GetSession {
372 pub id: String,
373 pub source_agent: String,
374 pub project: String,
375 pub created_at: DateTime<Utc>,
376}
377
378impl GetSession {
379 pub fn from_session(session: &Session) -> Self {
380 Self {
381 id: session.id.clone(),
382 source_agent: session.source_agent.clone(),
383 project: (*session.project).clone(),
384 created_at: session.created_at,
385 }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct MessageView {
393 pub id: String,
394 pub role: Role,
395 pub timestamp: DateTime<Utc>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub text: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub content: Option<String>,
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub parts_summary: Vec<PartSummary>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub parts: Option<Vec<ResponsePart>>,
406}
407
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412pub struct PartSummary {
413 pub kind: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub label: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub call_id: Option<String>,
418}
419
420impl PartSummary {
421 pub fn for_kind(kind: &PartKind) -> Option<Self> {
432 let (label, call_id) = match kind {
433 PartKind::Text { .. } | PartKind::Reasoning { .. } => return None,
434 PartKind::File {
435 media_type,
436 file_name,
437 ..
438 } => (file_name.clone().or_else(|| media_type.clone()), None),
439 PartKind::ToolCall { name, call_id, .. } => {
440 (name.as_deref().cloned(), call_id.as_deref().cloned())
441 }
442 PartKind::ToolResult {
443 name,
444 call_id,
445 is_failure,
446 ..
447 } => {
448 let label = name.as_deref().map(|name| {
449 if *is_failure {
450 format!("{name} (failed)")
451 } else {
452 name.clone()
453 }
454 });
455 (label, call_id.as_deref().cloned())
456 }
457 PartKind::ToolApprovalRequest { approval_id, .. } => (Some(approval_id.clone()), None),
458 PartKind::ToolApprovalResponse {
459 approval_id,
460 approved,
461 ..
462 } => {
463 let verb = if *approved { "approved" } else { "denied" };
464 (Some(format!("{approval_id} ({verb})")), None)
465 }
466 };
467 Some(Self {
468 kind: kind.type_name().to_owned(),
469 label,
470 call_id,
471 })
472 }
473}
474
475pub const SUMMARY_PART_TYPES: &[&str] = &[
480 "file",
481 "tool_call",
482 "tool_result",
483 "tool_approval_request",
484 "tool_approval_response",
485];
486
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491pub struct ResponsePart {
492 pub id: String,
493 pub ordinal: i32,
494 pub provenance: Provenance,
495 #[serde(default, skip_serializing_if = "ProviderOptions::is_empty")]
496 pub options: ProviderOptions,
497 #[serde(flatten)]
498 pub kind: PartKind,
499}
500
501impl ResponsePart {
502 pub fn from_part(part: Part) -> Self {
503 Self {
504 id: part.id,
505 ordinal: part.ordinal,
506 provenance: part.provenance,
507 options: part.options,
508 kind: part.kind,
509 }
510 }
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
516#[serde(tag = "scope", rename_all = "snake_case")]
517pub enum GetResult {
518 Session {
519 messages: Vec<MessageView>,
520 messages_remaining: usize,
521 },
522 Message {
523 target: MessageView,
524 target_parts: Vec<ResponsePart>,
525 target_parts_remaining: usize,
526 siblings: Vec<MessageView>,
528 },
529}
530
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(untagged)]
533pub enum SearchEnvelope {
534 Success(SearchResponse),
535 Error(ErrorEnvelope),
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum ProjectFilter {
543 Contains(String),
544 Regex(String),
545}
546
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
548pub struct SearchRequest {
549 pub protocol_version: u16,
550 #[serde(default)]
551 pub namespace: Option<String>,
552 pub query: String,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub mode_override: Option<SearchModeWire>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub similar_to: Option<String>,
567 #[serde(default)]
568 pub filters: SearchFilters,
569 #[serde(default = "default_limit")]
570 pub limit: usize,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub cursor: Option<String>,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "lowercase")]
582pub enum SearchModeWire {
583 Fts,
584 Vector,
585 Hybrid,
586}
587
588#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
589pub struct SearchFilters {
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub project: Option<ProjectFilter>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub session_id: Option<String>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub source_agent: Option<String>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub from_date: Option<String>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub to_date: Option<String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub role: Option<String>,
602 #[serde(default, skip_serializing_if = "is_zero_f64")]
604 pub min_score: f64,
605}
606
607impl SearchFilters {
608 fn is_default(&self) -> bool {
609 *self == Self::default()
610 }
611}
612
613fn is_zero_f64(value: &f64) -> bool {
614 *value == 0.0
615}
616
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct SearchResponse {
619 pub sessions: Vec<SearchSession>,
620 pub matched_total: usize,
621 pub has_more: bool,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub next_cursor: Option<String>,
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
627pub struct SearchSession {
628 pub session_id: String,
629 pub project: String,
630 pub source_agent: String,
631 pub session_messages_count: usize,
632 pub matched_message_count: usize,
633 pub matches: Vec<SearchResult>,
634}
635
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637pub struct SearchResult {
638 pub message_id: String,
639 pub role: Role,
640 pub timestamp: DateTime<Utc>,
641 pub text: String,
642 pub score: f64,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub parts_summary: Vec<PartSummary>,
647}
648
649#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
650pub struct SearchCursor {
651 pub query: String,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub similar_to: Option<String>,
654 #[serde(default, skip_serializing_if = "SearchFilters::is_default")]
655 pub filters: SearchFilters,
656 pub offset: usize,
657}
658
659#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
660#[serde(untagged)]
661pub enum IngestEnvelope {
662 Success(IngestResponse),
663 Error(ErrorEnvelope),
664}
665
666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
667pub struct IngestRequest {
668 pub protocol_version: u16,
669 #[serde(default)]
670 pub namespace: Option<String>,
671 pub events: Vec<crate::sessions::IngestEvent>,
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
681pub struct IngestResponse {
682 pub accepted: usize,
683 pub rejected: usize,
684 pub results: Vec<IngestResult>,
685}
686
687#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
689pub struct IngestResult {
690 pub index: usize,
692 pub kind: String,
694 pub pk: Value,
698 pub status: IngestStatus,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub error: Option<ErrorBody>,
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(rename_all = "snake_case")]
707pub enum IngestStatus {
708 Inserted,
710 Matched,
712 Error,
714}
715
716fn default_limit() -> usize {
717 10
718}
719
720pub fn new_request_id() -> String {
721 format!("req_{}", Uuid::now_v7())
722}
723
724pub const DEFAULT_NAMESPACE: &str = "local";
725
726pub fn default_namespace() -> String {
727 DEFAULT_NAMESPACE.to_owned()
728}
729
730fn default_get_limit() -> usize {
731 20
732}
733
734pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
735 if version == PROTOCOL_VERSION {
736 return Ok(());
737 }
738
739 Err(error(
740 ErrorCode::VersionUnsupported,
741 "unsupported protocol_version",
742 serde_json::json!({
743 "received": version,
744 "supported": [PROTOCOL_VERSION],
745 }),
746 ))
747}
748
749pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
750 ErrorEnvelope {
751 error: ErrorBody {
752 code,
753 message: message.into(),
754 details,
755 },
756 }
757}
758
759impl From<crate::Error> for ErrorEnvelope {
760 fn from(error_value: crate::Error) -> Self {
761 match error_value {
762 crate::Error::Validation {
763 message,
764 field,
765 value,
766 expected,
767 } => error(
768 ErrorCode::ValidationFailed,
769 message,
770 validation_details(field, value, expected),
771 ),
772 crate::Error::NotFound { message, kind, pk } => error(
773 ErrorCode::NotFound,
774 message,
775 serde_json::json!({ "kind": kind, "pk": pk }),
776 ),
777 crate::Error::NamespaceUnknown { namespace } => error(
778 ErrorCode::NamespaceUnknown,
779 "namespace unknown",
780 serde_json::json!({ "namespace": namespace }),
781 ),
782 crate::Error::Conflict { attempts } => error(
783 ErrorCode::Conflict,
784 "commit conflict after retries exhausted",
785 serde_json::json!({ "attempts": attempts }),
786 ),
787 crate::Error::Storage(error_value) => storage_error(error_value),
788 crate::Error::Internal(message) => {
789 error(ErrorCode::Internal, message, serde_json::json!({}))
790 }
791 }
792 }
793}
794
795fn validation_details(
796 field: Option<String>,
797 value: Option<Value>,
798 expected: Option<String>,
799) -> Value {
800 let mut details = Map::new();
801 if let Some(field) = field {
802 details.insert("field".to_owned(), Value::String(field));
803 }
804 if let Some(value) = value {
805 details.insert("value".to_owned(), value);
806 }
807 if let Some(expected) = expected {
808 details.insert("expected".to_owned(), Value::String(expected));
809 }
810 Value::Object(details)
811}
812
813pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
814 error(
815 ErrorCode::StorageUnavailable,
816 "storage operation failed",
817 serde_json::json!({ "underlying": error_value.to_string() }),
818 )
819}
820
821#[cfg(test)]
822mod tests {
823 #![allow(clippy::expect_used, clippy::unwrap_used)]
824
825 use super::*;
826 use serde_json::json;
827
828 #[test]
829 fn wire_envelope_carries_conflict_code_and_attempts_detail() {
830 let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
831 assert_eq!(envelope.error.code, ErrorCode::Conflict);
832 assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
833 }
834}