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)]
331pub struct GetSessionRequest {
332 pub protocol_version: u16,
333 #[serde(default)]
334 pub namespace: Option<String>,
335 #[serde(alias = "session_id")]
336 pub id: String,
337 #[serde(default = "default_get_limit")]
339 pub limit: usize,
340 #[serde(default)]
344 pub from: SessionFrom,
345 #[serde(default)]
347 pub after_message_id: Option<String>,
348 #[serde(default)]
350 pub before_message_id: Option<String>,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct GetMessageRequest {
359 pub protocol_version: u16,
360 #[serde(default)]
361 pub namespace: Option<String>,
362 #[serde(alias = "message_id")]
363 pub id: String,
364 #[serde(default = "default_context")]
366 pub context_before: usize,
367 #[serde(default = "default_context")]
369 pub context_after: usize,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
375#[serde(rename_all = "lowercase")]
376pub enum SessionFrom {
377 #[default]
379 Start,
380 End,
382}
383
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct GetResponse {
390 pub session: GetSession,
391 #[serde(flatten)]
392 pub result: GetResult,
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct GetSession {
400 pub id: String,
401 pub source_agent: String,
402 pub project: String,
403 pub created_at: DateTime<Utc>,
404}
405
406impl GetSession {
407 pub fn from_session(session: &Session) -> Self {
408 Self {
409 id: session.id.clone(),
410 source_agent: session.source_agent.clone(),
411 project: (*session.project).clone(),
412 created_at: session.created_at,
413 }
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422pub struct MessageView {
423 pub id: String,
424 pub role: Role,
425 pub timestamp: DateTime<Utc>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub text: Option<String>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub content: Option<String>,
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub parts_summary: Vec<PartSummary>,
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440pub struct PartSummary {
441 pub kind: String,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub label: Option<String>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub call_id: Option<String>,
446}
447
448impl PartSummary {
449 pub fn for_kind(kind: &PartKind) -> Option<Self> {
460 let (label, call_id) = match kind {
461 PartKind::Text { .. } | PartKind::Reasoning { .. } => return None,
462 PartKind::File {
463 media_type,
464 file_name,
465 ..
466 } => (file_name.clone().or_else(|| media_type.clone()), None),
467 PartKind::ToolCall { name, call_id, .. } => {
468 (name.as_deref().cloned(), call_id.as_deref().cloned())
469 }
470 PartKind::ToolResult {
471 name,
472 call_id,
473 is_failure,
474 ..
475 } => {
476 let label = name.as_deref().map(|name| {
477 if *is_failure {
478 format!("{name} (failed)")
479 } else {
480 name.clone()
481 }
482 });
483 (label, call_id.as_deref().cloned())
484 }
485 PartKind::ToolApprovalRequest { approval_id, .. } => (Some(approval_id.clone()), None),
486 PartKind::ToolApprovalResponse {
487 approval_id,
488 approved,
489 ..
490 } => {
491 let verb = if *approved { "approved" } else { "denied" };
492 (Some(format!("{approval_id} ({verb})")), None)
493 }
494 };
495 Some(Self {
496 kind: kind.type_name().to_owned(),
497 label,
498 call_id,
499 })
500 }
501}
502
503pub const SUMMARY_PART_TYPES: &[&str] = &[
508 "file",
509 "tool_call",
510 "tool_result",
511 "tool_approval_request",
512 "tool_approval_response",
513];
514
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519pub struct ResponsePart {
520 pub id: String,
521 pub ordinal: i32,
522 pub provenance: Provenance,
523 #[serde(default, skip_serializing_if = "ProviderOptions::is_empty")]
524 pub options: ProviderOptions,
525 #[serde(flatten)]
526 pub kind: PartKind,
527}
528
529impl ResponsePart {
530 pub fn from_part(part: Part) -> Self {
531 Self {
532 id: part.id,
533 ordinal: part.ordinal,
534 provenance: part.provenance,
535 options: part.options,
536 kind: part.kind,
537 }
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544#[serde(tag = "scope", rename_all = "snake_case")]
545pub enum GetResult {
546 Session {
547 messages: Vec<MessageView>,
548 before_remaining: usize,
551 after_remaining: usize,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
558 resolved_from_message_id: Option<String>,
559 },
560 Message {
561 target: MessageView,
562 target_parts: Vec<ResponsePart>,
563 target_parts_remaining: usize,
564 siblings: Vec<MessageView>,
567 context_before: usize,
569 context_after: usize,
571 },
572}
573
574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
575#[serde(untagged)]
576pub enum SearchEnvelope {
577 Success(SearchResponse),
578 Error(ErrorEnvelope),
579}
580
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(rename_all = "snake_case")]
585pub enum ProjectFilter {
586 Contains(String),
587 Regex(String),
588}
589
590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591pub struct SearchRequest {
592 pub protocol_version: u16,
593 #[serde(default)]
594 pub namespace: Option<String>,
595 pub query: String,
596 #[serde(default)]
601 pub mode: SearchModeWire,
602 #[serde(default)]
607 pub sort_by: SortBy,
608 #[serde(default)]
609 pub filters: SearchFilters,
610 #[serde(default = "default_limit")]
611 pub limit: usize,
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
618#[serde(rename_all = "lowercase")]
619pub enum SearchModeWire {
620 Fts,
621 #[default]
622 Vector,
623}
624
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
627#[serde(rename_all = "lowercase")]
628pub enum SortBy {
629 #[default]
631 Relevance,
632 Recency,
634}
635
636#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
637pub struct SearchFilters {
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub project: Option<ProjectFilter>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub session_id: Option<String>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub source_agent: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub from_date: Option<String>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub to_date: Option<String>,
652 #[serde(default, skip_serializing_if = "is_zero_f64")]
660 pub min_score: f64,
661}
662
663fn is_zero_f64(value: &f64) -> bool {
664 *value == 0.0
665}
666
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
668pub struct SearchResponse {
669 pub sessions: Vec<SearchSession>,
670 pub matched_total: usize,
671 #[serde(default)]
676 pub searchable_in_scope: usize,
677 pub has_more: bool,
678}
679
680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
681pub struct SearchSession {
682 pub session_id: String,
683 pub project: String,
684 pub source_agent: String,
685 pub session_messages_count: usize,
686 pub matched_message_count: usize,
687 pub matches: Vec<SearchResult>,
688}
689
690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
691pub struct SearchResult {
692 pub message_id: String,
693 pub role: Role,
694 pub timestamp: DateTime<Utc>,
695 pub text: String,
696 pub score: f64,
697 #[serde(default, skip_serializing_if = "Vec::is_empty")]
700 pub parts_summary: Vec<PartSummary>,
701}
702
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(untagged)]
705pub enum IngestEnvelope {
706 Success(IngestResponse),
707 Error(ErrorEnvelope),
708}
709
710#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
711pub struct IngestRequest {
712 pub protocol_version: u16,
713 #[serde(default)]
714 pub namespace: Option<String>,
715 pub events: Vec<crate::sessions::IngestEvent>,
716}
717
718#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
725pub struct IngestResponse {
726 pub accepted: usize,
727 pub rejected: usize,
728 pub results: Vec<IngestResult>,
729}
730
731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
733pub struct IngestResult {
734 pub index: usize,
736 pub kind: String,
738 pub pk: Value,
742 pub status: IngestStatus,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub error: Option<ErrorBody>,
747}
748
749#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(rename_all = "snake_case")]
751pub enum IngestStatus {
752 Inserted,
754 Matched,
756 Error,
758}
759
760fn default_limit() -> usize {
761 10
762}
763
764pub fn new_request_id() -> String {
765 format!("req_{}", Uuid::now_v7())
766}
767
768pub const DEFAULT_NAMESPACE: &str = "local";
769
770pub fn default_namespace() -> String {
771 DEFAULT_NAMESPACE.to_owned()
772}
773
774fn default_get_limit() -> usize {
775 20
776}
777
778fn default_context() -> usize {
779 3
780}
781
782pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
783 if version == PROTOCOL_VERSION {
784 return Ok(());
785 }
786
787 Err(error(
788 ErrorCode::VersionUnsupported,
789 "unsupported protocol_version",
790 serde_json::json!({
791 "received": version,
792 "supported": [PROTOCOL_VERSION],
793 }),
794 ))
795}
796
797pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
798 ErrorEnvelope {
799 error: ErrorBody {
800 code,
801 message: message.into(),
802 details,
803 },
804 }
805}
806
807impl From<crate::Error> for ErrorEnvelope {
808 fn from(error_value: crate::Error) -> Self {
809 match error_value {
810 crate::Error::Validation {
811 message,
812 field,
813 value,
814 expected,
815 } => error(
816 ErrorCode::ValidationFailed,
817 message,
818 validation_details(field, value, expected),
819 ),
820 crate::Error::NotFound { message, kind, pk } => error(
821 ErrorCode::NotFound,
822 message,
823 serde_json::json!({ "kind": kind, "pk": pk }),
824 ),
825 crate::Error::NamespaceUnknown { namespace } => error(
826 ErrorCode::NamespaceUnknown,
827 "namespace unknown",
828 serde_json::json!({ "namespace": namespace }),
829 ),
830 crate::Error::Conflict { attempts } => error(
831 ErrorCode::Conflict,
832 "commit conflict after retries exhausted",
833 serde_json::json!({ "attempts": attempts }),
834 ),
835 crate::Error::Storage(error_value) => storage_error(error_value),
836 crate::Error::Internal(message) => {
837 error(ErrorCode::Internal, message, serde_json::json!({}))
838 }
839 }
840 }
841}
842
843fn validation_details(
844 field: Option<String>,
845 value: Option<Value>,
846 expected: Option<String>,
847) -> Value {
848 let mut details = Map::new();
849 if let Some(field) = field {
850 details.insert("field".to_owned(), Value::String(field));
851 }
852 if let Some(value) = value {
853 details.insert("value".to_owned(), value);
854 }
855 if let Some(expected) = expected {
856 details.insert("expected".to_owned(), Value::String(expected));
857 }
858 Value::Object(details)
859}
860
861pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
862 error(
863 ErrorCode::StorageUnavailable,
864 "storage operation failed",
865 serde_json::json!({ "underlying": error_value.to_string() }),
866 )
867}
868
869#[cfg(test)]
870mod tests {
871 #![allow(clippy::expect_used, clippy::unwrap_used)]
872
873 use super::*;
874 use serde_json::json;
875
876 #[test]
877 fn wire_envelope_carries_conflict_code_and_attempts_detail() {
878 let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
879 assert_eq!(envelope.error.code, ErrorCode::Conflict);
880 assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
881 }
882}