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 options_mut(&mut self) -> &mut ProviderOptions {
119 match self {
120 Self::System { options, .. }
121 | Self::User { options, .. }
122 | Self::Assistant { options, .. }
123 | Self::Tool { options, .. } => options,
124 }
125 }
126
127 pub fn system_content(&self) -> Option<&str> {
128 match self {
129 Self::System { content, .. } => content.as_deref().map(|e| &**e),
133 Self::User { .. } | Self::Assistant { .. } | Self::Tool { .. } => None,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Role {
141 System,
142 User,
143 Assistant,
144 Tool,
145}
146
147impl Role {
148 pub fn as_str(self) -> &'static str {
149 match self {
150 Self::System => "system",
151 Self::User => "user",
152 Self::Assistant => "assistant",
153 Self::Tool => "tool",
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum Provenance {
165 Conversational,
166 Injected,
167}
168
169impl Provenance {
170 pub fn as_str(self) -> &'static str {
171 match self {
172 Self::Conversational => "conversational",
173 Self::Injected => "injected",
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct Part {
180 pub session_id: String,
181 pub id: String,
182 pub message_id: String,
183 pub ordinal: i32,
184 pub provenance: Provenance,
187 #[serde(default)]
188 pub options: ProviderOptions,
189 #[serde(flatten)]
190 pub kind: PartKind,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum PartKind {
196 Text {
197 #[serde(default, skip_serializing_if = "Option::is_none")]
202 text: Option<Extracted<String>>,
203 },
204 Reasoning {
205 #[serde(default, skip_serializing_if = "Option::is_none")]
210 text: Option<Extracted<String>>,
211 },
212 File {
213 #[serde(default, skip_serializing_if = "Option::is_none")]
218 media_type: Option<String>,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 file_name: Option<String>,
221 data: FileData,
222 },
223 ToolCall {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
228 call_id: Option<Extracted<String>>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
234 name: Option<Extracted<String>>,
235 params: Value,
236 provider_executed: bool,
237 },
238 ToolResult {
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 call_id: Option<Extracted<String>>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
249 name: Option<Extracted<String>>,
250 is_failure: bool,
251 result: Value,
252 },
253 ToolApprovalRequest {
254 approval_id: String,
255 tool_call_id: String,
256 },
257 ToolApprovalResponse {
258 approval_id: String,
259 approved: bool,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 reason: Option<String>,
262 },
263}
264
265impl PartKind {
266 pub fn type_name(&self) -> &'static str {
267 match self {
268 Self::Text { .. } => "text",
269 Self::Reasoning { .. } => "reasoning",
270 Self::File { .. } => "file",
271 Self::ToolCall { .. } => "tool_call",
272 Self::ToolResult { .. } => "tool_result",
273 Self::ToolApprovalRequest { .. } => "tool_approval_request",
274 Self::ToolApprovalResponse { .. } => "tool_approval_response",
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
281pub enum FileData {
282 String(String),
283 Bytes(Vec<u8>),
284 Url(String),
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum ErrorCode {
290 ValidationFailed,
291 VersionUnsupported,
292 NotFound,
293 NamespaceUnknown,
294 StorageUnavailable,
295 Conflict,
296 Internal,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct ErrorBody {
301 pub code: ErrorCode,
302 pub message: String,
303 #[serde(default)]
304 pub details: Value,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct ErrorEnvelope {
309 pub error: ErrorBody,
310}
311
312#[allow(clippy::large_enum_variant)]
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[serde(untagged)]
318pub enum GetEnvelope {
319 Success(GetResponse),
320 Error(ErrorEnvelope),
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct GetRequest {
325 pub protocol_version: u16,
326 #[serde(default)]
327 pub namespace: Option<String>,
328 #[serde(default)]
331 pub session_id: Option<String>,
332 #[serde(default)]
333 pub message_id: Option<String>,
334 #[serde(default = "default_get_limit")]
336 pub session_limit: usize,
337 #[serde(default)]
341 pub session_from: SessionFrom,
342 #[serde(default)]
344 pub session_after_message_id: Option<String>,
345 #[serde(default)]
347 pub session_before_message_id: Option<String>,
348 #[serde(default = "default_context")]
351 pub message_context_before: usize,
352 #[serde(default = "default_context")]
355 pub message_context_after: usize,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
361#[serde(rename_all = "lowercase")]
362pub enum SessionFrom {
363 #[default]
365 Start,
366 End,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub struct GetResponse {
376 pub session: GetSession,
377 #[serde(flatten)]
378 pub result: GetResult,
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct GetSession {
386 pub id: String,
387 pub source_agent: String,
388 pub project: String,
389 pub created_at: DateTime<Utc>,
390}
391
392impl GetSession {
393 pub fn from_session(session: &Session) -> Self {
394 Self {
395 id: session.id.clone(),
396 source_agent: session.source_agent.clone(),
397 project: (*session.project).clone(),
398 created_at: session.created_at,
399 }
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct MessageView {
409 pub id: String,
410 pub role: Role,
411 pub timestamp: DateTime<Utc>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub text: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub content: Option<String>,
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub parts_summary: Vec<PartSummary>,
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426pub struct PartSummary {
427 pub kind: String,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub label: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub call_id: Option<String>,
432}
433
434impl PartSummary {
435 pub fn for_kind(kind: &PartKind) -> Option<Self> {
446 let (label, call_id) = match kind {
447 PartKind::Text { .. } | PartKind::Reasoning { .. } => return None,
448 PartKind::File {
449 media_type,
450 file_name,
451 ..
452 } => (file_name.clone().or_else(|| media_type.clone()), None),
453 PartKind::ToolCall { name, call_id, .. } => {
454 (name.as_deref().cloned(), call_id.as_deref().cloned())
455 }
456 PartKind::ToolResult {
457 name,
458 call_id,
459 is_failure,
460 ..
461 } => {
462 let label = name.as_deref().map(|name| {
463 if *is_failure {
464 format!("{name} (failed)")
465 } else {
466 name.clone()
467 }
468 });
469 (label, call_id.as_deref().cloned())
470 }
471 PartKind::ToolApprovalRequest { approval_id, .. } => (Some(approval_id.clone()), None),
472 PartKind::ToolApprovalResponse {
473 approval_id,
474 approved,
475 ..
476 } => {
477 let verb = if *approved { "approved" } else { "denied" };
478 (Some(format!("{approval_id} ({verb})")), None)
479 }
480 };
481 Some(Self {
482 kind: kind.type_name().to_owned(),
483 label,
484 call_id,
485 })
486 }
487}
488
489pub const SUMMARY_PART_TYPES: &[&str] = &[
494 "file",
495 "tool_call",
496 "tool_result",
497 "tool_approval_request",
498 "tool_approval_response",
499];
500
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct ResponsePart {
506 pub id: String,
507 pub ordinal: i32,
508 pub provenance: Provenance,
509 #[serde(default, skip_serializing_if = "ProviderOptions::is_empty")]
510 pub options: ProviderOptions,
511 #[serde(flatten)]
512 pub kind: PartKind,
513}
514
515impl ResponsePart {
516 pub fn from_part(part: Part) -> Self {
517 Self {
518 id: part.id,
519 ordinal: part.ordinal,
520 provenance: part.provenance,
521 options: part.options,
522 kind: part.kind,
523 }
524 }
525}
526
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530#[serde(tag = "scope", rename_all = "snake_case")]
531pub enum GetResult {
532 Session {
533 messages: Vec<MessageView>,
534 before_remaining: usize,
537 after_remaining: usize,
540 },
541 Message {
542 target: MessageView,
543 target_parts: Vec<ResponsePart>,
544 target_parts_remaining: usize,
545 siblings: Vec<MessageView>,
548 },
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552#[serde(untagged)]
553pub enum SearchEnvelope {
554 Success(SearchResponse),
555 Error(ErrorEnvelope),
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561#[serde(rename_all = "snake_case")]
562pub enum ProjectFilter {
563 Contains(String),
564 Regex(String),
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
568pub struct SearchRequest {
569 pub protocol_version: u16,
570 #[serde(default)]
571 pub namespace: Option<String>,
572 pub query: String,
573 #[serde(default)]
578 pub mode: SearchModeWire,
579 #[serde(default)]
584 pub sort_by: SortBy,
585 #[serde(default)]
586 pub filters: SearchFilters,
587 #[serde(default = "default_limit")]
588 pub limit: usize,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
595#[serde(rename_all = "lowercase")]
596pub enum SearchModeWire {
597 Fts,
598 #[default]
599 Vector,
600}
601
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
604#[serde(rename_all = "lowercase")]
605pub enum SortBy {
606 #[default]
608 Relevance,
609 Recency,
611}
612
613#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
614pub struct SearchFilters {
615 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub project: Option<ProjectFilter>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub session_id: Option<String>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub from_date: Option<String>,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub to_date: Option<String>,
623 #[serde(default, skip_serializing_if = "is_zero_f64")]
631 pub min_score: f64,
632}
633
634fn is_zero_f64(value: &f64) -> bool {
635 *value == 0.0
636}
637
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct SearchResponse {
640 pub sessions: Vec<SearchSession>,
641 pub matched_total: usize,
642 #[serde(default)]
647 pub searchable_in_scope: usize,
648 pub has_more: bool,
649}
650
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652pub struct SearchSession {
653 pub session_id: String,
654 pub project: String,
655 pub source_agent: String,
656 pub session_messages_count: usize,
657 pub matched_message_count: usize,
658 pub matches: Vec<SearchResult>,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662pub struct SearchResult {
663 pub message_id: String,
664 pub role: Role,
665 pub timestamp: DateTime<Utc>,
666 pub text: String,
667 pub score: f64,
668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
671 pub parts_summary: Vec<PartSummary>,
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(untagged)]
676pub enum IngestEnvelope {
677 Success(IngestResponse),
678 Error(ErrorEnvelope),
679}
680
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct IngestRequest {
683 pub protocol_version: u16,
684 #[serde(default)]
685 pub namespace: Option<String>,
686 pub events: Vec<crate::sessions::IngestEvent>,
687}
688
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696pub struct IngestResponse {
697 pub accepted: usize,
698 pub rejected: usize,
699 pub results: Vec<IngestResult>,
700}
701
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704pub struct IngestResult {
705 pub index: usize,
707 pub kind: String,
709 pub pk: Value,
713 pub status: IngestStatus,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub error: Option<ErrorBody>,
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
721#[serde(rename_all = "snake_case")]
722pub enum IngestStatus {
723 Inserted,
725 Matched,
727 Error,
729}
730
731fn default_limit() -> usize {
732 10
733}
734
735pub fn new_request_id() -> String {
736 format!("req_{}", Uuid::now_v7())
737}
738
739pub const DEFAULT_NAMESPACE: &str = "local";
740
741pub fn default_namespace() -> String {
742 DEFAULT_NAMESPACE.to_owned()
743}
744
745fn default_get_limit() -> usize {
746 20
747}
748
749fn default_context() -> usize {
750 3
751}
752
753pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
754 if version == PROTOCOL_VERSION {
755 return Ok(());
756 }
757
758 Err(error(
759 ErrorCode::VersionUnsupported,
760 "unsupported protocol_version",
761 serde_json::json!({
762 "received": version,
763 "supported": [PROTOCOL_VERSION],
764 }),
765 ))
766}
767
768pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
769 ErrorEnvelope {
770 error: ErrorBody {
771 code,
772 message: message.into(),
773 details,
774 },
775 }
776}
777
778impl From<crate::Error> for ErrorEnvelope {
779 fn from(error_value: crate::Error) -> Self {
780 match error_value {
781 crate::Error::Validation {
782 message,
783 field,
784 value,
785 expected,
786 } => error(
787 ErrorCode::ValidationFailed,
788 message,
789 validation_details(field, value, expected),
790 ),
791 crate::Error::NotFound { message, kind, pk } => error(
792 ErrorCode::NotFound,
793 message,
794 serde_json::json!({ "kind": kind, "pk": pk }),
795 ),
796 crate::Error::NamespaceUnknown { namespace } => error(
797 ErrorCode::NamespaceUnknown,
798 "namespace unknown",
799 serde_json::json!({ "namespace": namespace }),
800 ),
801 crate::Error::Conflict { attempts } => error(
802 ErrorCode::Conflict,
803 "commit conflict after retries exhausted",
804 serde_json::json!({ "attempts": attempts }),
805 ),
806 crate::Error::Storage(error_value) => storage_error(error_value),
807 crate::Error::Internal(message) => {
808 error(ErrorCode::Internal, message, serde_json::json!({}))
809 }
810 }
811 }
812}
813
814fn validation_details(
815 field: Option<String>,
816 value: Option<Value>,
817 expected: Option<String>,
818) -> Value {
819 let mut details = Map::new();
820 if let Some(field) = field {
821 details.insert("field".to_owned(), Value::String(field));
822 }
823 if let Some(value) = value {
824 details.insert("value".to_owned(), value);
825 }
826 if let Some(expected) = expected {
827 details.insert("expected".to_owned(), Value::String(expected));
828 }
829 Value::Object(details)
830}
831
832pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
833 error(
834 ErrorCode::StorageUnavailable,
835 "storage operation failed",
836 serde_json::json!({ "underlying": error_value.to_string() }),
837 )
838}
839
840#[cfg(test)]
841mod tests {
842 #![allow(clippy::expect_used, clippy::unwrap_used)]
843
844 use super::*;
845 use serde_json::json;
846
847 #[test]
848 fn wire_envelope_carries_conflict_code_and_attempts_detail() {
849 let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
850 assert_eq!(envelope.error.code, ErrorCode::Conflict);
851 assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
852 }
853}