1use std::sync::Arc;
15
16use async_trait::async_trait;
17use base64::Engine as _;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use serde::Deserialize;
20use serde_json::{Value, json};
21
22use super::{MemoryEntry, MemoryError, MemoryService};
23
24const SOURCE_DISPLAY_NAME_PREFIX: &str = "adk-memory-v1.";
26
27#[derive(Debug, Clone)]
33pub struct VertexAiRagMemoryConfig {
34 pub corpus: String,
37 pub project: String,
39 pub location: String,
41 pub similarity_top_k: Option<u32>,
43 pub vector_distance_threshold: Option<f64>,
45}
46
47impl VertexAiRagMemoryConfig {
48 pub fn from_corpus(corpus: impl Into<String>) -> Self {
51 let corpus = corpus.into();
52 let (project, location) = parse_project_location(&corpus);
53 Self {
54 corpus,
55 project,
56 location,
57 similarity_top_k: None,
58 vector_distance_threshold: Some(10.0),
59 }
60 }
61
62 fn retrieve_contexts_url(&self) -> String {
64 format!(
65 "https://{location}-aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{location}:retrieveContexts",
66 project = self.project,
67 location = self.location,
68 )
69 }
70
71 fn upload_rag_file_url(&self) -> String {
76 format!(
77 "https://{location}-aiplatform.googleapis.com/upload/v1beta1/{corpus}/ragFiles:upload",
78 location = self.location,
79 corpus = self.corpus,
80 )
81 }
82}
83
84fn parse_project_location(corpus: &str) -> (String, String) {
87 let mut project = String::new();
88 let mut location = String::new();
89 let mut parts = corpus.split('/');
90 while let Some(seg) = parts.next() {
91 match seg {
92 "projects" => project = parts.next().unwrap_or_default().to_string(),
93 "locations" => location = parts.next().unwrap_or_default().to_string(),
94 _ => {}
95 }
96 }
97 (project, location)
98}
99
100fn encode_part(value: &str) -> String {
105 URL_SAFE_NO_PAD.encode(value.as_bytes())
106}
107
108fn decode_part(value: &str) -> Option<String> {
109 URL_SAFE_NO_PAD
110 .decode(value.as_bytes())
111 .ok()
112 .and_then(|bytes| String::from_utf8(bytes).ok())
113}
114
115pub(crate) fn build_source_display_name(app_name: &str, user_id: &str, session_id: &str) -> String {
118 format!(
119 "{prefix}{a}.{u}.{s}",
120 prefix = SOURCE_DISPLAY_NAME_PREFIX,
121 a = encode_part(app_name),
122 u = encode_part(user_id),
123 s = encode_part(session_id),
124 )
125}
126
127pub(crate) fn parse_source_display_name(name: &str) -> Option<(String, String, String)> {
131 if let Some(rest) = name.strip_prefix(SOURCE_DISPLAY_NAME_PREFIX) {
132 let parts: Vec<&str> = rest.split('.').collect();
133 if parts.len() != 3 {
134 return None;
135 }
136 return Some((
137 decode_part(parts[0])?,
138 decode_part(parts[1])?,
139 decode_part(parts[2])?,
140 ));
141 }
142 let parts: Vec<&str> = name.split('.').collect();
144 if parts.len() != 3 {
145 return None;
146 }
147 Some((
148 parts[0].to_string(),
149 parts[1].to_string(),
150 parts[2].to_string(),
151 ))
152}
153
154enum TokenProvider {
159 None,
160 Static(String),
161 Refresher(Arc<dyn Fn() -> String + Send + Sync>),
162}
163
164impl TokenProvider {
165 fn get(&self) -> Result<String, MemoryError> {
166 match self {
167 TokenProvider::None => Err(MemoryError::Storage(
168 "missing auth token: call .with_token() or .with_token_refresher()".into(),
169 )),
170 TokenProvider::Static(t) => Ok(t.clone()),
171 TokenProvider::Refresher(f) => Ok(f()),
172 }
173 }
174}
175
176#[derive(Debug, Default, Deserialize)]
181#[serde(rename_all = "camelCase")]
182struct RetrieveContextsResponse {
183 #[serde(default)]
184 contexts: RagContexts,
185}
186
187#[derive(Debug, Default, Deserialize)]
188#[serde(rename_all = "camelCase")]
189struct RagContexts {
190 #[serde(default)]
191 contexts: Vec<RagContext>,
192}
193
194#[derive(Debug, Default, Deserialize)]
195#[serde(rename_all = "camelCase")]
196struct RagContext {
197 #[serde(default)]
198 text: String,
199 #[serde(default)]
200 source_display_name: String,
201}
202
203fn build_retrieve_body(
205 query: &str,
206 corpus: &str,
207 similarity_top_k: Option<u32>,
208 vector_distance_threshold: Option<f64>,
209) -> Value {
210 let mut vertex_rag_store = json!({
211 "ragResources": [ { "ragCorpus": corpus } ],
212 });
213 if let Some(threshold) = vector_distance_threshold {
214 vertex_rag_store["vectorDistanceThreshold"] = json!(threshold);
215 }
216 let mut query_obj = json!({ "text": query });
217 if let Some(top_k) = similarity_top_k {
218 query_obj["ragRetrievalConfig"] = json!({ "topK": top_k });
219 }
220 json!({
221 "vertexRagStore": vertex_rag_store,
222 "query": query_obj,
223 })
224}
225
226pub struct VertexAiRagMemoryService {
252 config: VertexAiRagMemoryConfig,
253 client: reqwest::Client,
254 token_provider: TokenProvider,
255}
256
257impl VertexAiRagMemoryService {
258 pub fn new(config: VertexAiRagMemoryConfig) -> Self {
264 Self {
265 config,
266 client: reqwest::Client::new(),
267 token_provider: TokenProvider::None,
268 }
269 }
270
271 pub fn with_token(mut self, token: impl Into<String>) -> Self {
273 self.token_provider = TokenProvider::Static(token.into());
274 self
275 }
276
277 pub fn with_token_refresher(mut self, f: impl Fn() -> String + Send + Sync + 'static) -> Self {
279 self.token_provider = TokenProvider::Refresher(Arc::new(f));
280 self
281 }
282
283 pub fn corpus(&self) -> &str {
285 &self.config.corpus
286 }
287
288 async fn upload_text(&self, contents: &str, display_name: &str) -> Result<(), MemoryError> {
293 let token = self.token_provider.get()?;
294 let url = self.config.upload_rag_file_url();
295
296 let metadata = json!({
298 "rag_file": { "display_name": display_name },
299 });
300 let form = reqwest::multipart::Form::new()
301 .text("metadata", metadata.to_string())
302 .part(
303 "file",
304 reqwest::multipart::Part::text(contents.to_string())
305 .file_name(format!("{display_name}.txt"))
306 .mime_str("text/plain")
307 .map_err(|e| MemoryError::Storage(format!("invalid mime: {e}")))?,
308 );
309
310 let resp = self
311 .client
312 .post(&url)
313 .header("Authorization", format!("Bearer {token}"))
314 .header("X-Goog-Upload-Protocol", "multipart")
315 .multipart(form)
316 .send()
317 .await
318 .map_err(|e| MemoryError::Storage(format!("HTTP upload failed: {e}")))?;
319
320 let status = resp.status().as_u16();
321 if !(200..300).contains(&status) {
322 let body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
323 return Err(MemoryError::Storage(format!(
324 "Vertex AI RAG upload failed [{status}]: {body}"
325 )));
326 }
327 Ok(())
328 }
329
330 pub async fn add_session_to_memory(
338 &self,
339 app_name: &str,
340 user_id: &str,
341 session_id: &str,
342 entries: &[MemoryEntry],
343 ) -> Result<(), MemoryError> {
344 let mut lines = Vec::new();
345 for entry in entries {
346 let text = match &entry.value {
347 Value::String(s) => s.replace('\n', " "),
348 other => other.to_string(),
349 };
350 lines.push(
351 json!({
352 "author": entry.key,
353 "timestamp": entry.updated_at,
354 "text": text,
355 })
356 .to_string(),
357 );
358 }
359 let contents = lines.join("\n");
360 let display_name = build_source_display_name(app_name, user_id, session_id);
361 self.upload_text(&contents, &display_name).await
362 }
363
364 async fn retrieve_contexts(&self, query: &str) -> Result<Vec<RagContext>, MemoryError> {
367 let token = self.token_provider.get()?;
368 let url = self.config.retrieve_contexts_url();
369 let body = build_retrieve_body(
370 query,
371 &self.config.corpus,
372 self.config.similarity_top_k,
373 self.config.vector_distance_threshold,
374 );
375
376 let resp = self
377 .client
378 .post(&url)
379 .header("Authorization", format!("Bearer {token}"))
380 .header("Content-Type", "application/json")
381 .json(&body)
382 .send()
383 .await
384 .map_err(|e| MemoryError::Storage(format!("HTTP request failed: {e}")))?;
385
386 let status = resp.status().as_u16();
387 if !(200..300).contains(&status) {
388 let err_body = resp.text().await.unwrap_or_else(|_| "<unreadable>".into());
389 return Err(MemoryError::Storage(format!(
390 "Vertex AI RAG retrieveContexts failed [{status}]: {err_body}"
391 )));
392 }
393
394 let parsed: RetrieveContextsResponse = resp.json().await.map_err(|e| {
395 MemoryError::Storage(format!("failed to parse retrieveContexts response: {e}"))
396 })?;
397 Ok(parsed.contexts.contexts)
398 }
399
400 pub async fn search_memory(
404 &self,
405 app_name: &str,
406 user_id: &str,
407 query: &str,
408 ) -> Result<Vec<MemoryEntry>, MemoryError> {
409 let contexts = self.retrieve_contexts(query).await?;
410 Ok(parse_scoped_contexts(contexts, Some((app_name, user_id))))
411 }
412}
413
414fn parse_scoped_contexts(
417 contexts: Vec<RagContext>,
418 scope: Option<(&str, &str)>,
419) -> Vec<MemoryEntry> {
420 let mut out = Vec::new();
421 for ctx in contexts {
422 if let Some((app_name, user_id)) = scope {
423 match parse_source_display_name(&ctx.source_display_name) {
424 Some((src_app, src_user, _session)) => {
425 if src_app != app_name || src_user != user_id {
426 continue;
427 }
428 }
429 None => continue,
430 }
431 }
432 for line in ctx.text.split('\n') {
433 let line = line.trim();
434 if line.is_empty() {
435 continue;
436 }
437 if let Ok(event) = serde_json::from_str::<Value>(line) {
438 let author = event
439 .get("author")
440 .and_then(Value::as_str)
441 .unwrap_or("")
442 .to_string();
443 let timestamp = event
444 .get("timestamp")
445 .and_then(|t| t.as_u64().or_else(|| t.as_f64().map(|f| f as u64)))
446 .unwrap_or(0);
447 let text = event
448 .get("text")
449 .and_then(Value::as_str)
450 .unwrap_or("")
451 .to_string();
452 out.push(MemoryEntry {
453 key: author,
454 value: Value::String(text),
455 created_at: timestamp,
456 updated_at: timestamp,
457 });
458 }
459 }
460 }
461 out
462}
463
464#[async_trait]
465impl MemoryService for VertexAiRagMemoryService {
466 async fn store(&self, session_id: &str, entry: MemoryEntry) -> Result<(), MemoryError> {
470 self.add_session_to_memory(session_id, session_id, session_id, &[entry])
473 .await
474 }
475
476 async fn get(&self, _session_id: &str, _key: &str) -> Result<Option<MemoryEntry>, MemoryError> {
479 Ok(None)
480 }
481
482 async fn list(&self, _session_id: &str) -> Result<Vec<MemoryEntry>, MemoryError> {
484 Ok(vec![])
485 }
486
487 async fn search(
491 &self,
492 _session_id: &str,
493 query: &str,
494 ) -> Result<Vec<MemoryEntry>, MemoryError> {
495 let contexts = self.retrieve_contexts(query).await?;
496 Ok(parse_scoped_contexts(contexts, None))
497 }
498
499 async fn delete(&self, _session_id: &str, _key: &str) -> Result<(), MemoryError> {
503 Err(MemoryError::Unsupported(
504 "VertexAiRagMemoryService cannot delete individual entries (semantic retrieval API); manage the corpus via the Vertex AI admin API"
505 .into(),
506 ))
507 }
508
509 async fn clear(&self, _session_id: &str) -> Result<(), MemoryError> {
512 Err(MemoryError::Unsupported(
513 "VertexAiRagMemoryService cannot clear a corpus (destructive admin operation); use the Vertex AI admin API"
514 .into(),
515 ))
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 fn test_config() -> VertexAiRagMemoryConfig {
524 VertexAiRagMemoryConfig::from_corpus(
525 "projects/test/locations/us-central1/ragCorpora/test-corpus",
526 )
527 }
528
529 #[test]
530 fn service_metadata() {
531 let svc = VertexAiRagMemoryService::new(test_config());
532 assert!(svc.corpus().contains("test-corpus"));
533 }
534
535 #[test]
536 fn config_parses_project_location() {
537 let cfg = test_config();
538 assert_eq!(cfg.project, "test");
539 assert_eq!(cfg.location, "us-central1");
540 assert!(cfg.retrieve_contexts_url().ends_with(":retrieveContexts"));
541 assert!(cfg.upload_rag_file_url().ends_with("/ragFiles:upload"));
542 }
543
544 #[test]
545 fn display_name_roundtrip() {
546 let dn = build_source_display_name("my-app", "user-1", "sess-42");
547 assert!(dn.starts_with(SOURCE_DISPLAY_NAME_PREFIX));
548 let (a, u, s) = parse_source_display_name(&dn).expect("should parse");
549 assert_eq!(a, "my-app");
550 assert_eq!(u, "user-1");
551 assert_eq!(s, "sess-42");
552 }
553
554 #[test]
555 fn display_name_handles_dots_in_ids() {
556 let dn = build_source_display_name("a.b", "c.d", "e.f");
558 let (a, u, s) = parse_source_display_name(&dn).expect("should parse");
559 assert_eq!((a.as_str(), u.as_str(), s.as_str()), ("a.b", "c.d", "e.f"));
560 }
561
562 #[test]
563 fn parse_legacy_plain_display_name() {
564 let (a, u, s) = parse_source_display_name("app.user.session").expect("legacy ok");
565 assert_eq!(
566 (a.as_str(), u.as_str(), s.as_str()),
567 ("app", "user", "session")
568 );
569 }
570
571 #[test]
572 fn parse_rejects_malformed_display_name() {
573 assert!(parse_source_display_name("not-a-valid-name").is_none());
574 assert!(parse_source_display_name("adk-memory-v1.onlyonepart").is_none());
575 }
576
577 #[test]
578 fn builds_retrieve_body() {
579 let body = build_retrieve_body("q", "corpus-x", Some(3), Some(10.0));
580 assert_eq!(body["query"]["text"], "q");
581 assert_eq!(body["query"]["ragRetrievalConfig"]["topK"], 3);
582 assert_eq!(
583 body["vertexRagStore"]["ragResources"][0]["ragCorpus"],
584 "corpus-x"
585 );
586 assert_eq!(body["vertexRagStore"]["vectorDistanceThreshold"], 10.0);
587 }
588
589 #[test]
590 fn parse_scoped_contexts_filters_by_scope() {
591 let dn_match = build_source_display_name("app", "alice", "s1");
592 let dn_other = build_source_display_name("app", "bob", "s2");
593 let contexts = vec![
594 RagContext {
595 text: json!({"author": "user", "timestamp": 100, "text": "hello"}).to_string(),
596 source_display_name: dn_match,
597 },
598 RagContext {
599 text: json!({"author": "user", "timestamp": 200, "text": "nope"}).to_string(),
600 source_display_name: dn_other,
601 },
602 ];
603 let entries = parse_scoped_contexts(contexts, Some(("app", "alice")));
604 assert_eq!(entries.len(), 1);
605 assert_eq!(entries[0].key, "user");
606 assert_eq!(entries[0].value, json!("hello"));
607 assert_eq!(entries[0].created_at, 100);
608 }
609
610 #[test]
611 fn parse_scoped_contexts_no_scope_keeps_all() {
612 let contexts = vec![RagContext {
613 text: json!({"author": "model", "timestamp": 5, "text": "hi"}).to_string(),
614 source_display_name: String::new(),
615 }];
616 let entries = parse_scoped_contexts(contexts, None);
617 assert_eq!(entries.len(), 1);
618 assert_eq!(entries[0].key, "model");
619 }
620
621 #[tokio::test]
622 async fn search_without_token_errors() {
623 let svc = VertexAiRagMemoryService::new(test_config());
624 let result = svc.search("s1", "test").await;
625 assert!(result.is_err());
626 }
627}