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)]
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
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct ConversationRecord {
116 pub id: String,
119 pub scope: ConversationScope,
120 pub role: String,
122 pub content: Value,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub name: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub tool_call_id: Option<String>,
127 #[serde(default)]
130 pub sequence: i64,
131 #[serde(default)]
132 pub created_at_ms: i64,
133 #[serde(default, skip_serializing_if = "Value::is_null")]
134 pub metadata: Value,
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct AppendMessagesRequest {
140 pub messages: Vec<ConversationRecord>,
141}
142
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct AppendResponse {
148 pub appended: usize,
149 pub skipped: usize,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct RecentMessagesRequest {
155 pub scope: ConversationScope,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub limit: Option<usize>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub roles: Vec<String>,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct MessagesResponse {
171 pub items: Vec<ConversationRecord>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase", deny_unknown_fields)]
177pub struct MessageCursor {
178 pub sequence: i64,
179 pub id: String,
180}
181
182impl MessageCursor {
183 pub fn encode(&self) -> Result<String, serde_json::Error> {
184 serde_json::to_vec(self).map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
185 }
186
187 pub fn decode(value: &str) -> Result<Self, String> {
188 let bytes = URL_SAFE_NO_PAD
189 .decode(value)
190 .map_err(|_| "invalid message cursor encoding".to_string())?;
191 serde_json::from_slice(&bytes).map_err(|_| "invalid message cursor payload".to_string())
192 }
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct PageMessagesRequest {
198 pub scope: ConversationScope,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub cursor: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub limit: Option<usize>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub roles: Vec<String>,
205}
206
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct MessagePage {
210 pub items: Vec<ConversationRecord>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub next_cursor: Option<String>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase", deny_unknown_fields)]
217pub struct RetentionPolicy {
218 pub max_age_days: Option<u32>,
219 pub max_messages: Option<u32>,
220}
221
222impl Default for RetentionPolicy {
223 fn default() -> Self {
224 Self {
225 max_age_days: Some(90),
226 max_messages: Some(100_000),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase", deny_unknown_fields)]
233pub struct RetentionRequest {
234 pub scope: ConversationScope,
235 #[serde(default)]
236 pub policy: RetentionPolicy,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase", deny_unknown_fields)]
241pub struct DeleteConversationRequest {
242 pub scope: ConversationScope,
243 #[serde(default)]
244 pub reason: String,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct DeletionOperation {
250 pub id: String,
251 pub state: String,
252 pub deleted_messages: u64,
253 pub created_at_ms: i64,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub next_poll_after_ms: Option<u64>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub error_code: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase", deny_unknown_fields)]
262pub struct GetDeletionOperationRequest {
263 pub operation_id: String,
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ContextMaintenanceStatus {
269 pub projector_last_run_ms: Option<i64>,
270 pub projector_processed: u64,
271 pub retention_last_run_ms: Option<i64>,
272 pub retention_processed: u64,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub index_watermark: Option<IndexWatermark>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum ContextInsightStatus {
280 Active,
281 Corrected,
282 Merged,
283 Expired,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum ContextInsightKind {
289 Fact,
290 Preference,
291 Decision,
292 Task,
293 Summary,
294 Custom,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase", deny_unknown_fields)]
299pub struct ContextProvenance {
300 pub conversation_id: String,
301 #[serde(default)]
302 pub message_ids: Vec<String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub extractor_ref: Option<String>,
305 #[serde(default)]
306 pub observed_at_ms: i64,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct ContextInsight {
312 pub id: String,
313 pub kind: ContextInsightKind,
314 pub content: Value,
315 #[serde(default)]
316 pub attributes: Value,
317 pub version: u64,
318 pub status: ContextInsightStatus,
319 pub provenance: ContextProvenance,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub superseded_by: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub expires_at_ms: Option<i64>,
324 pub created_at_ms: i64,
325 pub updated_at_ms: i64,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase", deny_unknown_fields)]
330pub struct PutContextInsightRequest {
331 pub entry: ContextInsight,
332 #[serde(default)]
333 pub expected_version: u64,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase", deny_unknown_fields)]
338pub struct GetContextInsightRequest {
339 pub entry_id: String,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase", deny_unknown_fields)]
344pub struct CorrectContextInsightRequest {
345 pub entry_id: String,
346 pub expected_version: u64,
347 pub content: Value,
348 #[serde(default)]
349 pub reason: String,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", deny_unknown_fields)]
354pub struct ExpireContextInsightRequest {
355 pub entry_id: String,
356 pub expected_version: u64,
357 #[serde(default)]
358 pub reason: String,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase", deny_unknown_fields)]
363pub struct MergeContextInsightsRequest {
364 pub source_entry_ids: Vec<String>,
365 pub merged_entry: ContextInsight,
366 #[serde(default)]
367 pub expected_versions: std::collections::BTreeMap<String, u64>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct RecallContextRequest {
373 pub query: String,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub conversation_id: Option<String>,
376 #[serde(default = "default_recall_limit")]
377 pub limit: usize,
378 #[serde(default = "default_lexical_weight")]
379 pub lexical_weight: f64,
380 #[serde(default = "default_vector_weight")]
381 pub vector_weight: f64,
382}
383
384fn default_recall_limit() -> usize {
385 20
386}
387fn default_lexical_weight() -> f64 {
388 0.35
389}
390fn default_vector_weight() -> f64 {
391 0.65
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(rename_all = "camelCase")]
396pub struct RecallScoreBreakdown {
397 pub lexical: f64,
398 pub vector: f64,
399 pub recency: f64,
400 pub final_score: f64,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct RecalledContext {
406 pub entry: ContextInsight,
407 pub score: RecallScoreBreakdown,
408}
409
410#[derive(Debug, Clone, Default, Serialize, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct IndexWatermark {
413 pub projected_version: u64,
414 pub source_version: u64,
415 pub lag: u64,
416 pub updated_at_ms: i64,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct RecallContextResponse {
422 pub items: Vec<RecalledContext>,
423 pub index_watermark: IndexWatermark,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "snake_case")]
428pub enum ContextOperationKind {
429 Delete,
430 Export,
431 Import,
432 RebuildVector,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum ContextOperationState {
438 Queued,
439 Running,
440 Succeeded,
441 Failed,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct ContextOperation {
447 pub id: String,
448 pub kind: ContextOperationKind,
449 pub state: ContextOperationState,
450 pub phase: String,
451 pub processed: u64,
452 pub total: u64,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub artifact_ref: Option<String>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub result: Option<Value>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub error: Option<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub error_code: Option<String>,
463 pub created_at_ms: i64,
464 pub updated_at_ms: i64,
465 pub version: u64,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub next_poll_after_ms: Option<u64>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "camelCase", deny_unknown_fields)]
472pub struct GetContextOperationRequest {
473 pub operation_id: String,
474}
475
476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase", deny_unknown_fields)]
478pub struct ExportContextRequest {
479 #[serde(default)]
480 pub include_expired: bool,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(rename_all = "camelCase", deny_unknown_fields)]
485pub struct ImportContextRequest {
486 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub artifact_ref: Option<String>,
490 #[serde(default)]
491 pub messages: Vec<ConversationRecord>,
492 #[serde(default)]
493 pub entries: Vec<ContextInsight>,
494 pub idempotency_key: String,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(rename_all = "camelCase")]
499pub struct ContextExportArtifact {
500 pub id: String,
501 pub schema_version: u32,
502 pub messages: Vec<ConversationRecord>,
503 pub entries: Vec<ContextInsight>,
504 pub created_at_ms: i64,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase", deny_unknown_fields)]
509pub struct GetContextExportArtifactRequest {
510 pub artifact_id: String,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct GetContextExportArtifactChunkRequest {
516 pub artifact_id: String,
517 #[serde(default)]
518 pub offset: u64,
519 #[serde(default = "default_artifact_chunk_bytes")]
520 pub limit: usize,
521}
522
523fn default_artifact_chunk_bytes() -> usize {
524 1024 * 1024
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct ContextExportArtifactChunk {
530 pub artifact_id: String,
531 pub offset: u64,
532 pub data_base64: String,
533 pub next_offset: u64,
534 pub eof: bool,
535 pub digest: String,
536}
537
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase", deny_unknown_fields)]
540pub struct RebuildVectorProjectionRequest {
541 #[serde(default)]
542 pub force: bool,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase")]
547pub struct SearchMessagesRequest {
548 pub scope: ConversationScope,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub query: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub role: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub limit: Option<usize>,
556}
557
558pub const MAX_PAGE_SIZE: usize = 500;
564
565pub const DEFAULT_PAGE_SIZE: usize = 50;
567
568pub const MAX_APPEND_MESSAGES: usize = 500;
570
571pub const MAX_APPEND_BYTES: usize = 8 * 1024 * 1024;
573pub const MAX_SEARCH_QUERY_BYTES: usize = 4 * 1024;
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct ApiError {
578 pub status: u16,
579 pub message: String,
580}
581
582pub fn content_text(content: &Value) -> String {
588 match content {
589 Value::String(value) => value.clone(),
590 Value::Array(parts) => parts
591 .iter()
592 .filter_map(|part| {
593 part.get("text")
594 .and_then(Value::as_str)
595 .or_else(|| part.as_str())
596 })
597 .collect::<Vec<_>>()
598 .join("\n"),
599 Value::Null => String::new(),
600 other => other.to_string(),
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use serde_json::json;
608
609 #[test]
610 fn scope_serialises_camel_case_and_omits_absent_provenance() {
611 let value =
612 serde_json::to_value(ConversationScope::conversation("conv-1")).expect("serialize");
613 assert_eq!(value, json!({"conversationId": "conv-1"}));
614 }
615
616 #[test]
617 fn content_text_reads_strings_and_part_arrays() {
618 assert_eq!(content_text(&json!("hello")), "hello");
619 assert_eq!(
620 content_text(&json!([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}])),
621 "a\nb"
622 );
623 assert_eq!(content_text(&Value::Null), "");
624 }
625
626 #[test]
627 fn a_message_round_trips_through_json() {
628 let record = ConversationRecord {
629 id: "m-1".into(),
630 scope: ConversationScope::conversation("conv-1"),
631 role: "assistant".into(),
632 content: json!("done"),
633 name: None,
634 tool_call_id: None,
635 sequence: 7,
636 created_at_ms: 1_700_000_000_000,
637 metadata: Value::Null,
638 };
639 let encoded = serde_json::to_value(&record).expect("serialize");
640 assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
641 let decoded: ConversationRecord = serde_json::from_value(encoded).expect("deserialize");
642 assert_eq!(decoded, record);
643 }
644}