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)]
587 pub filters: SearchFilters,
588 #[serde(default = "default_limit")]
589 pub limit: usize,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(rename_all = "lowercase")]
599pub enum SearchModeWire {
600 Fts,
601 Vector,
602 Hybrid,
603}
604
605#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
606pub struct SearchFilters {
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub project: Option<ProjectFilter>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub session_id: Option<String>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub source_agent: Option<String>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub from_date: Option<String>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub to_date: Option<String>,
617 #[serde(default, skip_serializing_if = "is_zero_f64")]
623 pub min_score: f64,
624 #[serde(default, skip_serializing_if = "is_false")]
627 pub include_subagents: bool,
628}
629
630fn is_false(value: &bool) -> bool {
631 !*value
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
749pub fn validate_protocol(version: u16) -> Result<(), ErrorEnvelope> {
750 if version == PROTOCOL_VERSION {
751 return Ok(());
752 }
753
754 Err(error(
755 ErrorCode::VersionUnsupported,
756 "unsupported protocol_version",
757 serde_json::json!({
758 "received": version,
759 "supported": [PROTOCOL_VERSION],
760 }),
761 ))
762}
763
764pub fn error(code: ErrorCode, message: impl Into<String>, details: Value) -> ErrorEnvelope {
765 ErrorEnvelope {
766 error: ErrorBody {
767 code,
768 message: message.into(),
769 details,
770 },
771 }
772}
773
774impl From<crate::Error> for ErrorEnvelope {
775 fn from(error_value: crate::Error) -> Self {
776 match error_value {
777 crate::Error::Validation {
778 message,
779 field,
780 value,
781 expected,
782 } => error(
783 ErrorCode::ValidationFailed,
784 message,
785 validation_details(field, value, expected),
786 ),
787 crate::Error::NotFound { message, kind, pk } => error(
788 ErrorCode::NotFound,
789 message,
790 serde_json::json!({ "kind": kind, "pk": pk }),
791 ),
792 crate::Error::NamespaceUnknown { namespace } => error(
793 ErrorCode::NamespaceUnknown,
794 "namespace unknown",
795 serde_json::json!({ "namespace": namespace }),
796 ),
797 crate::Error::Conflict { attempts } => error(
798 ErrorCode::Conflict,
799 "commit conflict after retries exhausted",
800 serde_json::json!({ "attempts": attempts }),
801 ),
802 crate::Error::Storage(error_value) => storage_error(error_value),
803 crate::Error::Internal(message) => {
804 error(ErrorCode::Internal, message, serde_json::json!({}))
805 }
806 }
807 }
808}
809
810fn validation_details(
811 field: Option<String>,
812 value: Option<Value>,
813 expected: Option<String>,
814) -> Value {
815 let mut details = Map::new();
816 if let Some(field) = field {
817 details.insert("field".to_owned(), Value::String(field));
818 }
819 if let Some(value) = value {
820 details.insert("value".to_owned(), value);
821 }
822 if let Some(expected) = expected {
823 details.insert("expected".to_owned(), Value::String(expected));
824 }
825 Value::Object(details)
826}
827
828pub fn storage_error(error_value: anyhow::Error) -> ErrorEnvelope {
829 error(
830 ErrorCode::StorageUnavailable,
831 "storage operation failed",
832 serde_json::json!({ "underlying": error_value.to_string() }),
833 )
834}
835
836#[cfg(test)]
837mod tests {
838 #![allow(clippy::expect_used, clippy::unwrap_used)]
839
840 use super::*;
841 use serde_json::json;
842
843 #[test]
844 fn wire_envelope_carries_conflict_code_and_attempts_detail() {
845 let envelope: ErrorEnvelope = crate::Error::Conflict { attempts: 3 }.into();
846 assert_eq!(envelope.error.code, ErrorCode::Conflict);
847 assert_eq!(envelope.error.details, json!({ "attempts": 3 }));
848 }
849}