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