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