use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
mod session_log;
pub use session_log::*;
mod records;
pub use records::*;
pub const SERVICE_NAME: &str = "agent-context-infra";
pub const DEFAULT_PORT: u16 = 5120;
pub const AUDIENCE: &str = "agent-context-infra";
pub const MAX_RESOURCE_ID_CHARS: usize = 191;
pub const SERVICE_TOKEN_ENV: &str = "AGENT_CONTEXT_SERVICE_TOKEN";
pub const HEALTH_PATH: &str = "/health";
pub const MESSAGES_APPEND_PATH: &str = "/internal/v1/context/messages/batch";
pub const MESSAGES_RECENT_PATH: &str = "/internal/v1/context/messages/recent";
pub const MESSAGES_SEARCH_PATH: &str = "/internal/v1/context/messages/search";
pub const MESSAGES_PAGE_PATH: &str = "/internal/v1/context/messages";
pub const CONVERSATIONS_LIST_PATH: &str = "/internal/v1/context/conversations:list";
pub const CONVERSATION_DELETE_PATH: &str = "/internal/v1/context/conversations/delete";
pub const RETENTION_PATH: &str = "/internal/v1/context/conversations/retention";
pub const CONTEXT_OPERATIONS_PATH: &str = "/internal/v1/context/operations";
pub const MAINTENANCE_STATUS_PATH: &str = "/internal/v1/context/maintenance/status";
pub const CONTEXT_INSIGHTS_PATH: &str = "/internal/v1/context/entries";
pub const CONTEXT_INSIGHT_GET_PATH: &str = "/internal/v1/context/entries/get";
pub const CONTEXT_RECALL_PATH: &str = "/internal/v1/context/recall";
pub const CONTEXT_MERGE_PATH: &str = "/internal/v1/context/entries/merge";
pub const CONTEXT_CORRECT_PATH: &str = "/internal/v1/context/entries/correct";
pub const CONTEXT_EXPIRE_PATH: &str = "/internal/v1/context/entries/expire";
pub const CONTEXT_EXPORT_PATH: &str = "/internal/v1/context/exports";
pub const CONTEXT_IMPORT_PATH: &str = "/internal/v1/context/imports";
pub const VECTOR_REBUILD_PATH: &str = "/internal/v1/context/projections/vector/rebuild";
pub const CONTEXT_ADVANCED_OPERATIONS_PATH: &str = "/internal/v1/context/advanced-operations";
pub const CONTEXT_EXPORT_ARTIFACT_PATH: &str = "/internal/v1/context/export-artifacts/get";
pub const CONTEXT_EXPORT_ARTIFACT_CHUNK_PATH: &str = "/internal/v1/context/export-artifacts/chunk";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationScope {
pub conversation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_space_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_type: Option<String>,
#[serde(default, skip_serializing_if = "relations_are_empty")]
pub relations: agent_registry_contract::ResourceRelations,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthorizedScope {
pub tenant_id: String,
pub project_id: String,
pub subject: String,
}
impl ConversationScope {
pub fn conversation(conversation_id: impl Into<String>) -> Self {
Self {
conversation_id: conversation_id.into(),
relations: Default::default(),
..Default::default()
}
}
}
fn relations_are_empty(relations: &agent_registry_contract::ResourceRelations) -> bool {
relations.agent_ids.is_empty()
&& relations.runtime_ids.is_empty()
&& relations.user_ids.is_empty()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationRecord {
pub id: String,
pub scope: ConversationScope,
pub role: String,
pub content: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default)]
pub sequence: i64,
#[serde(default)]
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub metadata: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendMessagesRequest {
pub messages: Vec<ConversationRecord>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendResponse {
pub appended: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecentMessagesRequest {
pub scope: ConversationScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesResponse {
pub items: Vec<ConversationRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MessageCursor {
pub sequence: i64,
pub id: String,
}
impl MessageCursor {
pub fn encode(&self) -> Result<String, serde_json::Error> {
serde_json::to_vec(self).map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
}
pub fn decode(value: &str) -> Result<Self, String> {
let bytes = URL_SAFE_NO_PAD
.decode(value)
.map_err(|_| "invalid message cursor encoding".to_string())?;
serde_json::from_slice(&bytes).map_err(|_| "invalid message cursor payload".to_string())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageMessagesRequest {
pub scope: ConversationScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagePage {
pub items: Vec<ConversationRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ListConversationsRequest {
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSummary {
pub conversation_id: String,
pub agent_id: String,
pub message_count: u64,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationsResponse {
pub items: Vec<ConversationSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RetentionPolicy {
pub max_age_days: Option<u32>,
pub max_messages: Option<u32>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
max_age_days: Some(90),
max_messages: Some(100_000),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RetentionRequest {
pub scope: ConversationScope,
#[serde(default)]
pub policy: RetentionPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DeleteConversationRequest {
pub scope: ConversationScope,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeletionOperation {
pub id: String,
pub state: String,
pub deleted_messages: u64,
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_poll_after_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetDeletionOperationRequest {
pub operation_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextMaintenanceStatus {
pub projector_last_run_ms: Option<i64>,
pub projector_processed: u64,
pub retention_last_run_ms: Option<i64>,
pub retention_processed: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index_watermark: Option<IndexWatermark>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextInsightStatus {
Active,
Corrected,
Merged,
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextInsightKind {
Fact,
Preference,
Decision,
Task,
Summary,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ContextProvenance {
pub conversation_id: String,
#[serde(default)]
pub message_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extractor_ref: Option<String>,
#[serde(default)]
pub observed_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextInsight {
pub id: String,
pub kind: ContextInsightKind,
pub content: Value,
#[serde(default)]
pub attributes: Value,
pub version: u64,
pub status: ContextInsightStatus,
pub provenance: ContextProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_ms: Option<i64>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PutContextInsightRequest {
pub entry: ContextInsight,
#[serde(default)]
pub expected_version: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetContextInsightRequest {
pub entry_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CorrectContextInsightRequest {
pub entry_id: String,
pub expected_version: u64,
pub content: Value,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExpireContextInsightRequest {
pub entry_id: String,
pub expected_version: u64,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MergeContextInsightsRequest {
pub source_entry_ids: Vec<String>,
pub merged_entry: ContextInsight,
#[serde(default)]
pub expected_versions: std::collections::BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RecallContextRequest {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default = "default_recall_limit")]
pub limit: usize,
#[serde(default = "default_lexical_weight")]
pub lexical_weight: f64,
#[serde(default = "default_vector_weight")]
pub vector_weight: f64,
}
fn default_recall_limit() -> usize {
20
}
fn default_lexical_weight() -> f64 {
0.35
}
fn default_vector_weight() -> f64 {
0.65
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecallScoreBreakdown {
pub lexical: f64,
pub vector: f64,
pub recency: f64,
pub final_score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecalledContext {
pub entry: ContextInsight,
pub score: RecallScoreBreakdown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexWatermark {
pub projected_version: u64,
pub source_version: u64,
pub lag: u64,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecallContextResponse {
pub items: Vec<RecalledContext>,
pub index_watermark: IndexWatermark,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextOperationKind {
Delete,
Export,
Import,
RebuildVector,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextOperationState {
Queued,
Running,
Succeeded,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextOperation {
pub id: String,
pub kind: ContextOperationKind,
pub state: ContextOperationState,
pub phase: String,
pub processed: u64,
pub total: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
pub version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_poll_after_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetContextOperationRequest {
pub operation_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExportContextRequest {
#[serde(default)]
pub include_expired: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ImportContextRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_ref: Option<String>,
#[serde(default)]
pub messages: Vec<ConversationRecord>,
#[serde(default)]
pub entries: Vec<ContextInsight>,
pub idempotency_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextExportArtifact {
pub id: String,
pub schema_version: u32,
pub messages: Vec<ConversationRecord>,
pub entries: Vec<ContextInsight>,
pub created_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetContextExportArtifactRequest {
pub artifact_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetContextExportArtifactChunkRequest {
pub artifact_id: String,
#[serde(default)]
pub offset: u64,
#[serde(default = "default_artifact_chunk_bytes")]
pub limit: usize,
}
fn default_artifact_chunk_bytes() -> usize {
1024 * 1024
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextExportArtifactChunk {
pub artifact_id: String,
pub offset: u64,
pub data_base64: String,
pub next_offset: u64,
pub eof: bool,
pub digest: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RebuildVectorProjectionRequest {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchMessagesRequest {
pub scope: ConversationScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
pub const MAX_PAGE_SIZE: usize = 500;
pub const DEFAULT_PAGE_SIZE: usize = 50;
pub const MAX_APPEND_MESSAGES: usize = 500;
pub const MAX_APPEND_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_SEARCH_QUERY_BYTES: usize = 4 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiError {
pub status: u16,
pub message: String,
}
pub fn content_text(content: &Value) -> String {
match content {
Value::String(value) => value.clone(),
Value::Array(parts) => parts
.iter()
.filter_map(|part| {
part.get("text")
.and_then(Value::as_str)
.or_else(|| part.as_str())
})
.collect::<Vec<_>>()
.join("\n"),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scope_serialises_camel_case_and_omits_absent_provenance() {
let value =
serde_json::to_value(ConversationScope::conversation("conv-1")).expect("serialize");
assert_eq!(value, json!({"conversationId": "conv-1"}));
}
#[test]
fn content_text_reads_strings_and_part_arrays() {
assert_eq!(content_text(&json!("hello")), "hello");
assert_eq!(
content_text(&json!([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}])),
"a\nb"
);
assert_eq!(content_text(&Value::Null), "");
}
#[test]
fn a_message_round_trips_through_json() {
let record = ConversationRecord {
id: "m-1".into(),
scope: ConversationScope::conversation("conv-1"),
role: "assistant".into(),
content: json!("done"),
name: None,
tool_call_id: None,
sequence: 7,
created_at_ms: 1_700_000_000_000,
metadata: Value::Null,
};
let encoded = serde_json::to_value(&record).expect("serialize");
assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
let decoded: ConversationRecord = serde_json::from_value(encoded).expect("deserialize");
assert_eq!(decoded, record);
}
}