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 #[serde(default)]
344 pub session_from: SessionFrom,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum ResponseMode {
352 #[default]
354 Conversational,
355 Complete,
357 Verbatim,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum SessionFrom {
365 #[default]
367 Start,
368 End,
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct GetResponse {
378 pub session: GetSession,
379 #[serde(flatten)]
380 pub result: GetResult,
381}
382
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct GetSession {
388 pub id: String,
389 pub source_agent: String,
390 pub project: String,
391 pub created_at: DateTime<Utc>,
392}
393
394impl GetSession {
395 pub fn from_session(session: &Session) -> Self {
396 Self {
397 id: session.id.clone(),
398 source_agent: session.source_agent.clone(),
399 project: (*session.project).clone(),
400 created_at: session.created_at,
401 }
402 }
403}
404
405#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub parts: Option<Vec<ResponsePart>>,
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428pub struct PartSummary {
429 pub kind: String,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub label: Option<String>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub call_id: Option<String>,
434}
435
436impl PartSummary {
437 pub fn for_kind(kind: &PartKind) -> Option<Self> {
448 let (label, call_id) = match kind {
449 PartKind::Text { .. } | PartKind::Reasoning { .. } => return None,
450 PartKind::File {
451 media_type,
452 file_name,
453 ..
454 } => (file_name.clone().or_else(|| media_type.clone()), None),
455 PartKind::ToolCall { name, call_id, .. } => {
456 (name.as_deref().cloned(), call_id.as_deref().cloned())
457 }
458 PartKind::ToolResult {
459 name,
460 call_id,
461 is_failure,
462 ..
463 } => {
464 let label = name.as_deref().map(|name| {
465 if *is_failure {
466 format!("{name} (failed)")
467 } else {
468 name.clone()
469 }
470 });
471 (label, call_id.as_deref().cloned())
472 }
473 PartKind::ToolApprovalRequest { approval_id, .. } => (Some(approval_id.clone()), None),
474 PartKind::ToolApprovalResponse {
475 approval_id,
476 approved,
477 ..
478 } => {
479 let verb = if *approved { "approved" } else { "denied" };
480 (Some(format!("{approval_id} ({verb})")), None)
481 }
482 };
483 Some(Self {
484 kind: kind.type_name().to_owned(),
485 label,
486 call_id,
487 })
488 }
489}
490
491pub const SUMMARY_PART_TYPES: &[&str] = &[
496 "file",
497 "tool_call",
498 "tool_result",
499 "tool_approval_request",
500 "tool_approval_response",
501];
502
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
507pub struct ResponsePart {
508 pub id: String,
509 pub ordinal: i32,
510 pub provenance: Provenance,
511 #[serde(default, skip_serializing_if = "ProviderOptions::is_empty")]
512 pub options: ProviderOptions,
513 #[serde(flatten)]
514 pub kind: PartKind,
515}
516
517impl ResponsePart {
518 pub fn from_part(part: Part) -> Self {
519 Self {
520 id: part.id,
521 ordinal: part.ordinal,
522 provenance: part.provenance,
523 options: part.options,
524 kind: part.kind,
525 }
526 }
527}
528
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532#[serde(tag = "scope", rename_all = "snake_case")]
533pub enum GetResult {
534 Session {
535 messages: Vec<MessageView>,
536 messages_remaining: usize,
537 },
538 Message {
539 target: MessageView,
540 target_parts: Vec<ResponsePart>,
541 target_parts_remaining: usize,
542 siblings: Vec<MessageView>,
544 },
545}
546
547#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
548#[serde(untagged)]
549pub enum SearchEnvelope {
550 Success(SearchResponse),
551 Error(ErrorEnvelope),
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum ProjectFilter {
559 Contains(String),
560 Regex(String),
561}
562
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
564pub struct SearchRequest {
565 pub protocol_version: u16,
566 #[serde(default)]
567 pub namespace: Option<String>,
568 pub query: String,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub mode_override: Option<SearchModeWire>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub similar_to: Option<String>,
583 #[serde(default)]
584 pub filters: SearchFilters,
585 #[serde(default = "default_limit")]
586 pub limit: usize,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub cursor: Option<String>,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(rename_all = "lowercase")]
598pub enum SearchModeWire {
599 Fts,
600 Vector,
601 Hybrid,
602}
603
604#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
605pub struct SearchFilters {
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub project: Option<ProjectFilter>,
608 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub session_id: Option<String>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub source_agent: Option<String>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub from_date: Option<String>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub to_date: Option<String>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub role: Option<String>,
618 #[serde(default, skip_serializing_if = "is_zero_f64")]
620 pub min_score: f64,
621 #[serde(default, skip_serializing_if = "is_false")]
624 pub include_subagents: bool,
625}
626
627impl SearchFilters {
628 fn is_default(&self) -> bool {
629 *self == Self::default()
630 }
631}
632
633fn is_false(value: &bool) -> bool {
634 !*value
635}
636
637fn is_zero_f64(value: &f64) -> bool {
638 *value == 0.0
639}
640
641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
642pub struct SearchResponse {
643 pub sessions: Vec<SearchSession>,
644 pub matched_total: usize,
645 pub has_more: bool,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub next_cursor: Option<String>,
648}
649
650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
651pub struct SearchSession {
652 pub session_id: String,
653 pub project: String,
654 pub source_agent: String,
655 pub session_messages_count: usize,
656 pub matched_message_count: usize,
657 pub matches: Vec<SearchResult>,
658}
659
660#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
661pub struct SearchResult {
662 pub message_id: String,
663 pub role: Role,
664 pub timestamp: DateTime<Utc>,
665 pub text: String,
666 pub score: f64,
667 #[serde(default, skip_serializing_if = "Vec::is_empty")]
670 pub parts_summary: Vec<PartSummary>,
671}
672
673#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
674pub struct SearchCursor {
675 pub query: String,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub similar_to: Option<String>,
678 #[serde(default, skip_serializing_if = "SearchFilters::is_default")]
679 pub filters: SearchFilters,
680 pub offset: usize,
681}
682
683#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
684#[serde(untagged)]
685pub enum IngestEnvelope {
686 Success(IngestResponse),
687 Error(ErrorEnvelope),
688}
689
690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
691pub struct IngestRequest {
692 pub protocol_version: u16,
693 #[serde(default)]
694 pub namespace: Option<String>,
695 pub events: Vec<crate::sessions::IngestEvent>,
696}
697
698#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
705pub struct IngestResponse {
706 pub accepted: usize,
707 pub rejected: usize,
708 pub results: Vec<IngestResult>,
709}
710
711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
713pub struct IngestResult {
714 pub index: usize,
716 pub kind: String,
718 pub pk: Value,
722 pub status: IngestStatus,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub error: Option<ErrorBody>,
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(rename_all = "snake_case")]
731pub enum IngestStatus {
732 Inserted,
734 Matched,
736 Error,
738}
739
740fn default_limit() -> usize {
741 10
742}
743
744pub fn new_request_id() -> String {
745 format!("req_{}", Uuid::now_v7())
746}
747
748pub const DEFAULT_NAMESPACE: &str = "local";
749
750pub fn default_namespace() -> String {
751 DEFAULT_NAMESPACE.to_owned()
752}
753
754fn default_get_limit() -> usize {
755 20
756}
757
758pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
759 if version == PROTOCOL_VERSION {
760 return Ok(());
761 }
762
763 Err(error(
764 ErrorCode::VersionUnsupported,
765 "unsupported protocol_version",
766 serde_json::json!({
767 "received": version,
768 "supported": [PROTOCOL_VERSION],
769 }),
770 ))
771}
772
773pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
774 ErrorEnvelope {
775 error: ErrorBody {
776 code,
777 message: message.into(),
778 details,
779 },
780 }
781}
782
783impl From<crate::Error> for ErrorEnvelope {
784 fn from(error_value: crate::Error) -> Self {
785 match error_value {
786 crate::Error::Validation {
787 message,
788 field,
789 value,
790 expected,
791 } => error(
792 ErrorCode::ValidationFailed,
793 message,
794 validation_details(field, value, expected),
795 ),
796 crate::Error::NotFound { message, kind, pk } => error(
797 ErrorCode::NotFound,
798 message,
799 serde_json::json!({ "kind": kind, "pk": pk }),
800 ),
801 crate::Error::NamespaceUnknown { namespace } => error(
802 ErrorCode::NamespaceUnknown,
803 "namespace unknown",
804 serde_json::json!({ "namespace": namespace }),
805 ),
806 crate::Error::Conflict { attempts } => error(
807 ErrorCode::Conflict,
808 "commit conflict after retries exhausted",
809 serde_json::json!({ "attempts": attempts }),
810 ),
811 crate::Error::Storage(error_value) => storage_error(error_value),
812 crate::Error::Internal(message) => {
813 error(ErrorCode::Internal, message, serde_json::json!({}))
814 }
815 }
816 }
817}
818
819fn validation_details(
820 field: Option<String>,
821 value: Option<Value>,
822 expected: Option<String>,
823) -> Value {
824 let mut details = Map::new();
825 if let Some(field) = field {
826 details.insert("field".to_owned(), Value::String(field));
827 }
828 if let Some(value) = value {
829 details.insert("value".to_owned(), value);
830 }
831 if let Some(expected) = expected {
832 details.insert("expected".to_owned(), Value::String(expected));
833 }
834 Value::Object(details)
835}
836
837pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
838 error(
839 ErrorCode::StorageUnavailable,
840 "storage operation failed",
841 serde_json::json!({ "underlying": error_value.to_string() }),
842 )
843}
844
845#[cfg(test)]
846mod tests {
847 #![allow(clippy::expect_used, clippy::unwrap_used)]
848
849 use super::*;
850 use serde_json::json;
851
852 #[test]
853 fn wire_envelope_carries_conflict_code_and_attempts_detail() {
854 let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
855 assert_eq!(envelope.error.code, ErrorCode::Conflict);
856 assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
857 }
858}