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