1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25mod session_log;
26pub use session_log::*;
27mod records;
28pub use records::*;
29
30pub const SERVICE_NAME: &str = "agent-context-infra";
31pub const DEFAULT_PORT: u16 = 5120;
32pub const AUDIENCE: &str = "agent-context-infra";
33pub const MAX_RESOURCE_ID_CHARS: usize = 191;
34
35pub const SERVICE_TOKEN_ENV: &str = "AGENT_CONTEXT_SERVICE_TOKEN";
37
38pub const HEALTH_PATH: &str = "/health";
39pub const MESSAGES_APPEND_PATH: &str = "/internal/v1/context/messages/batch";
40pub const MESSAGES_RECENT_PATH: &str = "/internal/v1/context/messages/recent";
41pub const MESSAGES_SEARCH_PATH: &str = "/internal/v1/context/messages/search";
42pub const MESSAGES_PAGE_PATH: &str = "/internal/v1/context/messages";
43pub const CONVERSATIONS_LIST_PATH: &str = "/internal/v1/context/conversations:list";
44pub const CONVERSATION_DELETE_PATH: &str = "/internal/v1/context/conversations/delete";
45pub const RETENTION_PATH: &str = "/internal/v1/context/conversations/retention";
46pub const CONTEXT_OPERATIONS_PATH: &str = "/internal/v1/context/operations";
47pub const MAINTENANCE_STATUS_PATH: &str = "/internal/v1/context/maintenance/status";
48pub const CONTEXT_INSIGHTS_PATH: &str = "/internal/v1/context/entries";
49pub const CONTEXT_INSIGHT_GET_PATH: &str = "/internal/v1/context/entries/get";
50pub const CONTEXT_RECALL_PATH: &str = "/internal/v1/context/recall";
51pub const CONTEXT_MERGE_PATH: &str = "/internal/v1/context/entries/merge";
52pub const CONTEXT_CORRECT_PATH: &str = "/internal/v1/context/entries/correct";
53pub const CONTEXT_EXPIRE_PATH: &str = "/internal/v1/context/entries/expire";
54pub const CONTEXT_EXPORT_PATH: &str = "/internal/v1/context/exports";
55pub const CONTEXT_IMPORT_PATH: &str = "/internal/v1/context/imports";
56pub const VECTOR_REBUILD_PATH: &str = "/internal/v1/context/projections/vector/rebuild";
57pub const CONTEXT_ADVANCED_OPERATIONS_PATH: &str = "/internal/v1/context/advanced-operations";
58pub const CONTEXT_EXPORT_ARTIFACT_PATH: &str = "/internal/v1/context/export-artifacts/get";
59pub const CONTEXT_EXPORT_ARTIFACT_CHUNK_PATH: &str = "/internal/v1/context/export-artifacts/chunk";
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct ConversationScope {
77 pub conversation_id: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub user_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub agent_id: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub agent_space_id: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub runtime_type: Option<String>,
86 #[serde(default, skip_serializing_if = "relations_are_empty")]
89 pub relations: agent_registry_contract::ResourceRelations,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct AuthorizedScope {
96 pub tenant_id: String,
97 pub project_id: String,
98 pub subject: String,
99}
100
101impl ConversationScope {
102 pub fn conversation(conversation_id: impl Into<String>) -> Self {
103 Self {
104 conversation_id: conversation_id.into(),
105 relations: Default::default(),
106 ..Default::default()
107 }
108 }
109}
110
111fn relations_are_empty(relations: &agent_registry_contract::ResourceRelations) -> bool {
112 relations.agent_ids.is_empty()
113 && relations.runtime_ids.is_empty()
114 && relations.user_ids.is_empty()
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct ConversationRecord {
125 pub id: String,
128 pub scope: ConversationScope,
129 pub role: String,
131 pub content: Value,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub name: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub tool_call_id: Option<String>,
136 #[serde(default)]
139 pub sequence: i64,
140 #[serde(default)]
141 pub created_at_ms: i64,
142 #[serde(default, skip_serializing_if = "Value::is_null")]
143 pub metadata: Value,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct AppendMessagesRequest {
149 pub messages: Vec<ConversationRecord>,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct AppendResponse {
157 pub appended: usize,
158 pub skipped: usize,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct RecentMessagesRequest {
164 pub scope: ConversationScope,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub limit: Option<usize>,
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub roles: Vec<String>,
171}
172
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct MessagesResponse {
180 pub items: Vec<ConversationRecord>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct MessageCursor {
187 pub sequence: i64,
188 pub id: String,
189}
190
191impl MessageCursor {
192 pub fn encode(&self) -> Result<String, serde_json::Error> {
193 serde_json::to_vec(self).map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
194 }
195
196 pub fn decode(value: &str) -> Result<Self, String> {
197 let bytes = URL_SAFE_NO_PAD
198 .decode(value)
199 .map_err(|_| "invalid message cursor encoding".to_string())?;
200 serde_json::from_slice(&bytes).map_err(|_| "invalid message cursor payload".to_string())
201 }
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct PageMessagesRequest {
207 pub scope: ConversationScope,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub cursor: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub limit: Option<usize>,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub roles: Vec<String>,
214}
215
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct MessagePage {
219 pub items: Vec<ConversationRecord>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub next_cursor: Option<String>,
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase", deny_unknown_fields)]
231pub struct ListConversationsRequest {
232 pub agent_id: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub limit: Option<usize>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct ConversationSummary {
240 pub conversation_id: String,
241 pub agent_id: String,
242 pub message_count: u64,
243 pub created_at_ms: i64,
244 pub updated_at_ms: i64,
245}
246
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct ConversationsResponse {
250 pub items: Vec<ConversationSummary>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct RetentionPolicy {
256 pub max_age_days: Option<u32>,
257 pub max_messages: Option<u32>,
258}
259
260impl Default for RetentionPolicy {
261 fn default() -> Self {
262 Self {
263 max_age_days: Some(90),
264 max_messages: Some(100_000),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct RetentionRequest {
272 pub scope: ConversationScope,
273 #[serde(default)]
274 pub policy: RetentionPolicy,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase", deny_unknown_fields)]
279pub struct DeleteConversationRequest {
280 pub scope: ConversationScope,
281 #[serde(default)]
282 pub reason: String,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct DeletionOperation {
288 pub id: String,
289 pub state: String,
290 pub deleted_messages: u64,
291 pub created_at_ms: i64,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub next_poll_after_ms: Option<u64>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub error_code: Option<String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase", deny_unknown_fields)]
300pub struct GetDeletionOperationRequest {
301 pub operation_id: String,
302}
303
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct ContextMaintenanceStatus {
307 pub projector_last_run_ms: Option<i64>,
308 pub projector_processed: u64,
309 pub retention_last_run_ms: Option<i64>,
310 pub retention_processed: u64,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub index_watermark: Option<IndexWatermark>,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum ContextInsightStatus {
318 Active,
319 Corrected,
320 Merged,
321 Expired,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case")]
326pub enum ContextInsightKind {
327 Fact,
328 Preference,
329 Decision,
330 Task,
331 Summary,
332 Custom,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase", deny_unknown_fields)]
337pub struct ContextProvenance {
338 pub conversation_id: String,
339 #[serde(default)]
340 pub message_ids: Vec<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub extractor_ref: Option<String>,
343 #[serde(default)]
344 pub observed_at_ms: i64,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct ContextInsight {
350 pub id: String,
351 pub kind: ContextInsightKind,
352 pub content: Value,
353 #[serde(default)]
354 pub attributes: Value,
355 pub version: u64,
356 pub status: ContextInsightStatus,
357 pub provenance: ContextProvenance,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub superseded_by: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub expires_at_ms: Option<i64>,
362 pub created_at_ms: i64,
363 pub updated_at_ms: i64,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(rename_all = "camelCase", deny_unknown_fields)]
368pub struct PutContextInsightRequest {
369 pub entry: ContextInsight,
370 #[serde(default)]
371 pub expected_version: u64,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct GetContextInsightRequest {
377 pub entry_id: String,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase", deny_unknown_fields)]
382pub struct CorrectContextInsightRequest {
383 pub entry_id: String,
384 pub expected_version: u64,
385 pub content: Value,
386 #[serde(default)]
387 pub reason: String,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase", deny_unknown_fields)]
392pub struct ExpireContextInsightRequest {
393 pub entry_id: String,
394 pub expected_version: u64,
395 #[serde(default)]
396 pub reason: String,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase", deny_unknown_fields)]
401pub struct MergeContextInsightsRequest {
402 pub source_entry_ids: Vec<String>,
403 pub merged_entry: ContextInsight,
404 #[serde(default)]
405 pub expected_versions: std::collections::BTreeMap<String, u64>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(rename_all = "camelCase", deny_unknown_fields)]
410pub struct RecallContextRequest {
411 pub query: String,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub conversation_id: Option<String>,
414 #[serde(default = "default_recall_limit")]
415 pub limit: usize,
416 #[serde(default = "default_lexical_weight")]
417 pub lexical_weight: f64,
418 #[serde(default = "default_vector_weight")]
419 pub vector_weight: f64,
420}
421
422fn default_recall_limit() -> usize {
423 20
424}
425fn default_lexical_weight() -> f64 {
426 0.35
427}
428fn default_vector_weight() -> f64 {
429 0.65
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct RecallScoreBreakdown {
435 pub lexical: f64,
436 pub vector: f64,
437 pub recency: f64,
438 pub final_score: f64,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct RecalledContext {
444 pub entry: ContextInsight,
445 pub score: RecallScoreBreakdown,
446}
447
448#[derive(Debug, Clone, Default, Serialize, Deserialize)]
449#[serde(rename_all = "camelCase")]
450pub struct IndexWatermark {
451 pub projected_version: u64,
452 pub source_version: u64,
453 pub lag: u64,
454 pub updated_at_ms: i64,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458#[serde(rename_all = "camelCase")]
459pub struct RecallContextResponse {
460 pub items: Vec<RecalledContext>,
461 pub index_watermark: IndexWatermark,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum ContextOperationKind {
467 Delete,
468 Export,
469 Import,
470 RebuildVector,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(rename_all = "snake_case")]
475pub enum ContextOperationState {
476 Queued,
477 Running,
478 Succeeded,
479 Failed,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct ContextOperation {
485 pub id: String,
486 pub kind: ContextOperationKind,
487 pub state: ContextOperationState,
488 pub phase: String,
489 pub processed: u64,
490 pub total: u64,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub artifact_ref: Option<String>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub result: Option<Value>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub error: Option<String>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub error_code: Option<String>,
501 pub created_at_ms: i64,
502 pub updated_at_ms: i64,
503 pub version: u64,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub next_poll_after_ms: Option<u64>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(rename_all = "camelCase", deny_unknown_fields)]
510pub struct GetContextOperationRequest {
511 pub operation_id: String,
512}
513
514#[derive(Debug, Clone, Default, Serialize, Deserialize)]
515#[serde(rename_all = "camelCase", deny_unknown_fields)]
516pub struct ExportContextRequest {
517 #[serde(default)]
518 pub include_expired: bool,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[serde(rename_all = "camelCase", deny_unknown_fields)]
523pub struct ImportContextRequest {
524 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub artifact_ref: Option<String>,
528 #[serde(default)]
529 pub messages: Vec<ConversationRecord>,
530 #[serde(default)]
531 pub entries: Vec<ContextInsight>,
532 pub idempotency_key: String,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
536#[serde(rename_all = "camelCase")]
537pub struct ContextExportArtifact {
538 pub id: String,
539 pub schema_version: u32,
540 pub messages: Vec<ConversationRecord>,
541 pub entries: Vec<ContextInsight>,
542 pub created_at_ms: i64,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase", deny_unknown_fields)]
547pub struct GetContextExportArtifactRequest {
548 pub artifact_id: String,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase", deny_unknown_fields)]
553pub struct GetContextExportArtifactChunkRequest {
554 pub artifact_id: String,
555 #[serde(default)]
556 pub offset: u64,
557 #[serde(default = "default_artifact_chunk_bytes")]
558 pub limit: usize,
559}
560
561fn default_artifact_chunk_bytes() -> usize {
562 1024 * 1024
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct ContextExportArtifactChunk {
568 pub artifact_id: String,
569 pub offset: u64,
570 pub data_base64: String,
571 pub next_offset: u64,
572 pub eof: bool,
573 pub digest: String,
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize)]
577#[serde(rename_all = "camelCase", deny_unknown_fields)]
578pub struct RebuildVectorProjectionRequest {
579 #[serde(default)]
580 pub force: bool,
581}
582
583#[derive(Debug, Clone, Default, Serialize, Deserialize)]
584#[serde(rename_all = "camelCase")]
585pub struct SearchMessagesRequest {
586 pub scope: ConversationScope,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub query: Option<String>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub role: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub limit: Option<usize>,
594}
595
596pub const MAX_PAGE_SIZE: usize = 500;
602
603pub const DEFAULT_PAGE_SIZE: usize = 50;
605
606pub const MAX_APPEND_MESSAGES: usize = 500;
608
609pub const MAX_APPEND_BYTES: usize = 8 * 1024 * 1024;
611pub const MAX_SEARCH_QUERY_BYTES: usize = 4 * 1024;
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct ApiError {
616 pub status: u16,
617 pub message: String,
618}
619
620pub fn content_text(content: &Value) -> String {
626 match content {
627 Value::String(value) => value.clone(),
628 Value::Array(parts) => parts
629 .iter()
630 .filter_map(|part| {
631 part.get("text")
632 .and_then(Value::as_str)
633 .or_else(|| part.as_str())
634 })
635 .collect::<Vec<_>>()
636 .join("\n"),
637 Value::Null => String::new(),
638 other => other.to_string(),
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use serde_json::json;
646
647 #[test]
648 fn scope_serialises_camel_case_and_omits_absent_provenance() {
649 let value =
650 serde_json::to_value(ConversationScope::conversation("conv-1")).expect("serialize");
651 assert_eq!(value, json!({"conversationId": "conv-1"}));
652 }
653
654 #[test]
655 fn content_text_reads_strings_and_part_arrays() {
656 assert_eq!(content_text(&json!("hello")), "hello");
657 assert_eq!(
658 content_text(&json!([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}])),
659 "a\nb"
660 );
661 assert_eq!(content_text(&Value::Null), "");
662 }
663
664 #[test]
665 fn a_message_round_trips_through_json() {
666 let record = ConversationRecord {
667 id: "m-1".into(),
668 scope: ConversationScope::conversation("conv-1"),
669 role: "assistant".into(),
670 content: json!("done"),
671 name: None,
672 tool_call_id: None,
673 sequence: 7,
674 created_at_ms: 1_700_000_000_000,
675 metadata: Value::Null,
676 };
677 let encoded = serde_json::to_value(&record).expect("serialize");
678 assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
679 let decoded: ConversationRecord = serde_json::from_value(encoded).expect("deserialize");
680 assert_eq!(decoded, record);
681 }
682}