use adk_core::{AdkError, ErrorCategory, ErrorComponent, Result, Tool, ToolContext};
use adk_gcp::{GcpErrorCodes, GcpErrorContext, GcpHttpClient, truncate_for_error};
use async_trait::async_trait;
use google_cloud_auth::credentials::Credentials;
use reqwest::Method;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tracing::debug;
const RAG_API_VERSION: &str = "v1beta1";
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const AUTH_HEADERS_TIMEOUT: Duration = Duration::from_secs(30);
const LIST_MAX_PAGES: usize = 1_000;
const ENV_GOOGLE_CLOUD_PROJECT: &str = "GOOGLE_CLOUD_PROJECT";
const ENV_GOOGLE_CLOUD_LOCATION: &str = "GOOGLE_CLOUD_LOCATION";
const ERROR_CODES: GcpErrorCodes = GcpErrorCodes {
invalid_input: "rag.vertex.invalid_input",
unauthorized: "rag.vertex.unauthorized",
forbidden: "rag.vertex.forbidden",
not_found: "rag.vertex.not_found",
rate_limited: "rag.vertex.rate_limited",
timeout: "rag.vertex.timeout",
unavailable: "rag.vertex.unavailable",
credentials_unavailable: "rag.vertex.credentials_unavailable",
invalid_response: "rag.vertex.invalid_response",
invalid_request: "rag.vertex.invalid_request",
upstream_error: "rag.vertex.upstream_error",
operation_failed: "rag.vertex.operation_failed",
};
fn error_context() -> GcpErrorContext {
GcpErrorContext::new(ErrorComponent::Memory, ERROR_CODES, "vertex rag")
}
#[derive(Debug, Clone)]
pub struct VertexRagConfig {
project_id: String,
location: String,
endpoint: Option<String>,
}
impl VertexRagConfig {
pub fn new(project_id: impl Into<String>, location: impl Into<String>) -> Self {
Self { project_id: project_id.into(), location: location.into(), endpoint: None }
}
pub fn from_env() -> Result<Self> {
let read = |key: &str| {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
let project_id = read(ENV_GOOGLE_CLOUD_PROJECT);
let location = read(ENV_GOOGLE_CLOUD_LOCATION);
match (project_id, location) {
(Some(project_id), Some(location)) => Ok(Self::new(project_id, location)),
(project_id, location) => {
let missing = [
(ENV_GOOGLE_CLOUD_PROJECT, project_id.is_none()),
(ENV_GOOGLE_CLOUD_LOCATION, location.is_none()),
]
.into_iter()
.filter_map(|(key, is_missing)| is_missing.then_some(key))
.collect::<Vec<_>>()
.join(", ");
Err(AdkError::new(
ErrorComponent::Memory,
ErrorCategory::InvalidInput,
"rag.vertex.missing_env",
format!(
"missing or blank environment variable(s): {missing}. Set them, or construct the config with VertexRagConfig::new",
),
)
.with_provider("vertex_ai"))
}
}
}
#[must_use]
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
fn endpoint(&self) -> String {
self.endpoint
.clone()
.unwrap_or_else(|| format!("https://{}-aiplatform.googleapis.com", self.location))
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RagCorpus {
pub name: Option<String>,
pub display_name: Option<String>,
pub description: Option<String>,
pub vector_db_config: Option<Value>,
pub corpus_status: Option<CorpusStatus>,
#[serde(deserialize_with = "lenient_i64")]
pub rag_files_count: Option<i64>,
pub create_time: Option<String>,
pub update_time: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CorpusStatus {
pub state: Option<String>,
pub error_status: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RagFile {
pub name: Option<String>,
pub display_name: Option<String>,
pub description: Option<String>,
#[serde(deserialize_with = "lenient_i64")]
pub size_bytes: Option<i64>,
pub rag_file_type: Option<String>,
pub file_status: Option<Value>,
pub create_time: Option<String>,
pub update_time: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RagContext {
pub source_uri: Option<String>,
pub source_display_name: Option<String>,
pub text: Option<String>,
pub score: Option<f64>,
pub chunk: Option<RagChunk>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RagChunk {
pub chunk_id: Option<String>,
pub file_id: Option<String>,
pub text: Option<String>,
pub page_span: Option<PageSpan>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PageSpan {
#[serde(deserialize_with = "lenient_i64")]
pub first_page: Option<i64>,
#[serde(deserialize_with = "lenient_i64")]
pub last_page: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct ListRagCorporaResponse {
rag_corpora: Vec<RagCorpus>,
next_page_token: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct ListRagFilesResponse {
rag_files: Vec<RagFile>,
next_page_token: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct RetrieveContextsResponse {
contexts: RagContextsEnvelope,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct RagContextsEnvelope {
contexts: Vec<RagContext>,
}
fn lenient_i64<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<i64>, D::Error> {
match Option::<Value>::deserialize(deserializer)? {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => Ok(number.as_i64()),
Some(Value::String(text)) => Ok(text.parse().ok()),
Some(_) => Ok(None),
}
}
#[derive(Debug, Clone)]
pub struct RagResource {
rag_corpus: String,
rag_file_ids: Vec<String>,
}
impl RagResource {
pub fn new(rag_corpus: impl Into<String>) -> Self {
Self { rag_corpus: rag_corpus.into(), rag_file_ids: Vec::new() }
}
#[must_use]
pub fn with_rag_file_ids(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.rag_file_ids = ids.into_iter().map(Into::into).collect();
self
}
}
#[derive(Debug, Clone)]
pub struct RetrieveContextsRequest {
query: String,
resources: Vec<RagResource>,
top_k: Option<u32>,
vector_distance_threshold: Option<f64>,
vector_similarity_threshold: Option<f64>,
}
impl RetrieveContextsRequest {
pub fn new(
query: impl Into<String>,
rag_corpora: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
query: query.into(),
resources: rag_corpora.into_iter().map(RagResource::new).collect(),
top_k: None,
vector_distance_threshold: None,
vector_similarity_threshold: None,
}
}
#[must_use]
pub fn with_resources(mut self, resources: impl IntoIterator<Item = RagResource>) -> Self {
self.resources = resources.into_iter().collect();
self
}
#[must_use]
pub fn similarity_top_k(mut self, top_k: u32) -> Self {
self.top_k = Some(top_k);
self
}
#[must_use]
pub fn vector_distance_threshold(mut self, threshold: f64) -> Self {
self.vector_distance_threshold = Some(threshold);
self
}
#[must_use]
pub fn vector_similarity_threshold(mut self, threshold: f64) -> Self {
self.vector_similarity_threshold = Some(threshold);
self
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRetrieveRequest<'a> {
vertex_rag_store: WireRagStore,
query: WireRagQuery<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRagStore {
rag_resources: Vec<WireRagResource>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRagResource {
rag_corpus: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
rag_file_ids: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRagQuery<'a> {
text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
rag_retrieval_config: Option<WireRetrievalConfig>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRetrievalConfig {
#[serde(skip_serializing_if = "Option::is_none")]
top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<WireRetrievalFilter>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireRetrievalFilter {
#[serde(skip_serializing_if = "Option::is_none")]
vector_distance_threshold: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
vector_similarity_threshold: Option<f64>,
}
pub struct VertexRagEngineClient {
client: GcpHttpClient,
project_id: String,
location: String,
}
impl std::fmt::Debug for VertexRagEngineClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VertexRagEngineClient")
.field("project_id", &self.project_id)
.field("location", &self.location)
.finish_non_exhaustive()
}
}
impl VertexRagEngineClient {
pub fn new_with_adc(config: VertexRagConfig) -> Result<Self> {
Self::build(config, None)
}
pub fn with_credentials(config: VertexRagConfig, credentials: Credentials) -> Result<Self> {
Self::build(config, Some(credentials))
}
fn build(config: VertexRagConfig, credentials: Option<Credentials>) -> Result<Self> {
let mut builder = GcpHttpClient::builder(error_context(), config.endpoint())
.api_version(RAG_API_VERSION)
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.request_timeout(HTTP_REQUEST_TIMEOUT)
.auth_timeout(AUTH_HEADERS_TIMEOUT);
if let Some(credentials) = credentials {
builder = builder.credentials(credentials);
}
Ok(Self {
client: builder.build()?,
project_id: config.project_id,
location: config.location,
})
}
pub fn location_path(&self) -> String {
format!("projects/{}/locations/{}", self.project_id, self.location)
}
fn corpus_resource_name(&self, corpus: &str) -> Result<String> {
let corpus = corpus.trim();
if corpus.is_empty() {
return Err(self
.client
.errors()
.invalid_input("rag corpus must be a corpus ID or a full resource name"));
}
if !corpus.contains('/') {
return Ok(format!("{}/ragCorpora/{corpus}", self.location_path()));
}
if corpus.starts_with("projects/") && corpus.contains("/ragCorpora/") {
return Ok(corpus.to_string());
}
Err(self.client.errors().invalid_input(format!(
"invalid rag corpus '{}': expected a bare corpus ID or a projects/*/locations/*/ragCorpora/* resource name",
truncate_for_error(corpus),
)))
}
pub async fn get_corpus(&self, corpus: &str) -> Result<RagCorpus> {
let name = self.corpus_resource_name(corpus)?;
debug!(rag.corpus = name.as_str(), "fetching rag corpus");
let request = self.client.request(Method::GET, &name).await?;
let value = self.client.send_value_allow_not_found(request).await?.ok_or_else(|| {
self.client.errors().error(
ErrorCategory::NotFound,
ERROR_CODES.not_found,
format!(
"rag corpus '{name}' was not found. Verify the corpus ID, project, and location; corpus creation is a provisioning concern outside this read-only client — create it in the Vertex AI console or with the RagCorpora API",
),
)
})?;
self.parse("ragCorpora get", value)
}
pub async fn ensure_corpus_ready(&self, corpus: &str) -> Result<RagCorpus> {
let corpus = self.get_corpus(corpus).await?;
let name = corpus.name.as_deref().unwrap_or("<unnamed>");
if let Some(status) = &corpus.corpus_status
&& status.state.as_deref() == Some("ERROR")
{
let reason = status.error_status.as_deref().unwrap_or("no error detail reported");
return Err(self.client.errors().error(
ErrorCategory::Unavailable,
ERROR_CODES.unavailable,
format!("rag corpus '{name}' is in the ERROR state: {reason}. Re-import the failed files or recreate the corpus before retrieving"),
));
}
if corpus.rag_files_count == Some(0) {
return Err(self.client.errors().invalid_input(format!(
"rag corpus '{name}' has no imported files, so retrieval would always return nothing. Import documents with the RagFiles import API or the Vertex AI console first",
)));
}
Ok(corpus)
}
pub async fn list_corpora(&self) -> Result<Vec<RagCorpus>> {
let path = format!("{}/ragCorpora", self.location_path());
let mut corpora = Vec::new();
let mut page_token: Option<String> = None;
for _ in 0..LIST_MAX_PAGES {
let mut request = self.client.request(Method::GET, &path).await?;
if let Some(token) = &page_token {
request = request.query(&[("pageToken", token)]);
}
let value = self.client.send_value(request).await?;
let page: ListRagCorporaResponse = self.parse("ragCorpora list", value)?;
corpora.extend(page.rag_corpora);
page_token = page.next_page_token.filter(|token| !token.is_empty());
if page_token.is_none() {
return Ok(corpora);
}
}
Err(self.client.errors().invalid_response(format!(
"ragCorpora list did not terminate within {LIST_MAX_PAGES} pages; the server kept returning page tokens",
)))
}
pub async fn list_rag_files(&self, corpus: &str) -> Result<Vec<RagFile>> {
let path = format!("{}/ragFiles", self.corpus_resource_name(corpus)?);
let mut files = Vec::new();
let mut page_token: Option<String> = None;
for _ in 0..LIST_MAX_PAGES {
let mut request = self.client.request(Method::GET, &path).await?;
if let Some(token) = &page_token {
request = request.query(&[("pageToken", token)]);
}
let value = self.client.send_value(request).await?;
let page: ListRagFilesResponse = self.parse("ragFiles list", value)?;
files.extend(page.rag_files);
page_token = page.next_page_token.filter(|token| !token.is_empty());
if page_token.is_none() {
return Ok(files);
}
}
Err(self.client.errors().invalid_response(format!(
"ragFiles list did not terminate within {LIST_MAX_PAGES} pages; the server kept returning page tokens",
)))
}
pub async fn retrieve_contexts(
&self,
request: &RetrieveContextsRequest,
) -> Result<Vec<RagContext>> {
if request.query.trim().is_empty() {
return Err(self.client.errors().invalid_input("retrieval query must not be blank"));
}
if request.resources.is_empty() {
return Err(self.client.errors().invalid_input(
"at least one rag corpus is required; pass a corpus ID or full resource name",
));
}
if request.vector_distance_threshold.is_some()
&& request.vector_similarity_threshold.is_some()
{
return Err(self.client.errors().invalid_input(
"vector_distance_threshold and vector_similarity_threshold are mutually exclusive; the ragRetrievalConfig filter is a oneof",
));
}
let rag_resources = request
.resources
.iter()
.map(|resource| {
Ok(WireRagResource {
rag_corpus: self.corpus_resource_name(&resource.rag_corpus)?,
rag_file_ids: resource.rag_file_ids.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
let filter = match (request.vector_distance_threshold, request.vector_similarity_threshold)
{
(None, None) => None,
(distance, similarity) => Some(WireRetrievalFilter {
vector_distance_threshold: distance,
vector_similarity_threshold: similarity,
}),
};
let rag_retrieval_config = if request.top_k.is_none() && filter.is_none() {
None
} else {
Some(WireRetrievalConfig { top_k: request.top_k, filter })
};
let body = WireRetrieveRequest {
vertex_rag_store: WireRagStore { rag_resources },
query: WireRagQuery { text: &request.query, rag_retrieval_config },
};
let path = format!("{}:retrieveContexts", self.location_path());
debug!(rag.corpora = request.resources.len(), "retrieving rag contexts");
let http_request = self.client.request(Method::POST, &path).await?.json(&body);
let value = self.client.send_value(http_request).await?;
let response: RetrieveContextsResponse = self.parse("retrieveContexts", value)?;
Ok(response.contexts.contexts)
}
fn parse<R: for<'de> Deserialize<'de>>(&self, operation: &str, value: Value) -> Result<R> {
serde_json::from_value(value).map_err(|error| {
let error = truncate_for_error(&error.to_string());
self.client
.errors()
.invalid_response(format!("failed to parse {operation} response: {error}"))
})
}
}
pub struct VertexAiRagRetrievalTool {
client: Arc<VertexRagEngineClient>,
rag_corpora: Vec<String>,
similarity_top_k: Option<u32>,
vector_distance_threshold: Option<f64>,
}
impl VertexAiRagRetrievalTool {
pub fn new(client: Arc<VertexRagEngineClient>, rag_corpora: Vec<String>) -> Self {
Self { client, rag_corpora, similarity_top_k: None, vector_distance_threshold: None }
}
#[must_use]
pub fn similarity_top_k(mut self, top_k: u32) -> Self {
self.similarity_top_k = Some(top_k);
self
}
#[must_use]
pub fn vector_distance_threshold(mut self, threshold: f64) -> Self {
self.vector_distance_threshold = Some(threshold);
self
}
}
#[async_trait]
impl Tool for VertexAiRagRetrievalTool {
fn name(&self) -> &str {
"vertex_rag_retrieval"
}
fn description(&self) -> &str {
"Retrieve the passages most relevant to a query from Vertex AI RAG Engine corpora"
}
fn parameters_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The natural-language query to retrieve relevant passages for"
}
},
"required": ["query"]
}))
}
fn is_read_only(&self) -> bool {
true
}
fn is_concurrency_safe(&self) -> bool {
true
}
async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
let query = args
.get("query")
.and_then(Value::as_str)
.ok_or_else(|| AdkError::tool("missing required 'query' parameter"))?;
let mut request = RetrieveContextsRequest::new(query, self.rag_corpora.clone());
if let Some(top_k) = self.similarity_top_k {
request = request.similarity_top_k(top_k);
}
if let Some(threshold) = self.vector_distance_threshold {
request = request.vector_distance_threshold(threshold);
}
let contexts = self.client.retrieve_contexts(&request).await?;
debug!(rag.contexts = contexts.len(), "vertex_rag_retrieval returned contexts");
Ok(Value::Array(contexts.iter().map(context_to_tool_output).collect()))
}
}
fn context_to_tool_output(context: &RagContext) -> Value {
let mut output = json!({ "text": context.text.as_deref().unwrap_or("") });
if let Some(source_uri) = &context.source_uri {
output["sourceUri"] = json!(source_uri);
}
if let Some(source_display_name) = &context.source_display_name {
output["sourceDisplayName"] = json!(source_display_name);
}
if let Some(score) = context.score {
output["score"] = json!(score);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> VertexRagEngineClient {
let credentials =
google_cloud_auth::credentials::api_key_credentials::Builder::new("test-key").build();
VertexRagEngineClient::with_credentials(
VertexRagConfig::new("proj", "us-central1"),
credentials,
)
.expect("build test client")
}
#[test]
fn endpoint_defaults_to_regional_origin() {
let config = VertexRagConfig::new("proj", "europe-west4");
assert_eq!(config.endpoint(), "https://europe-west4-aiplatform.googleapis.com");
}
#[tokio::test]
async fn corpus_names_resolve_bare_ids_and_pass_full_names_through() {
let client = client();
assert_eq!(
client.corpus_resource_name("1234").unwrap(),
"projects/proj/locations/us-central1/ragCorpora/1234",
);
let full = "projects/other/locations/eu/ragCorpora/9";
assert_eq!(client.corpus_resource_name(full).unwrap(), full);
assert_eq!(client.corpus_resource_name("").unwrap_err().http_status_code(), 400);
assert_eq!(client.corpus_resource_name("a/b/c").unwrap_err().http_status_code(), 400);
}
#[test]
fn deprecated_knob_names_serialize_on_the_modern_wire_path() {
let request = RetrieveContextsRequest::new("q", ["projects/p/locations/l/ragCorpora/1"])
.similarity_top_k(3)
.vector_distance_threshold(0.5);
let body = WireRetrieveRequest {
vertex_rag_store: WireRagStore {
rag_resources: vec![WireRagResource {
rag_corpus: "projects/p/locations/l/ragCorpora/1".into(),
rag_file_ids: vec![],
}],
},
query: WireRagQuery {
text: &request.query,
rag_retrieval_config: Some(WireRetrievalConfig {
top_k: request.top_k,
filter: Some(WireRetrievalFilter {
vector_distance_threshold: request.vector_distance_threshold,
vector_similarity_threshold: None,
}),
}),
},
};
assert_eq!(
serde_json::to_value(&body).unwrap(),
json!({
"vertexRagStore": {
"ragResources": [
{ "ragCorpus": "projects/p/locations/l/ragCorpora/1" }
]
},
"query": {
"text": "q",
"ragRetrievalConfig": {
"topK": 3,
"filter": { "vectorDistanceThreshold": 0.5 }
}
}
}),
);
}
#[test]
fn corpus_and_file_responses_deserialize_leniently() {
let corpus: RagCorpus = serde_json::from_value(json!({
"name": "projects/p/locations/l/ragCorpora/1",
"displayName": "docs",
"corpusStatus": { "state": "ACTIVE" },
"ragFilesCount": "12",
"someFutureField": { "nested": true },
}))
.unwrap();
assert_eq!(corpus.rag_files_count, Some(12));
assert_eq!(corpus.corpus_status.unwrap().state.as_deref(), Some("ACTIVE"));
let file: RagFile = serde_json::from_value(json!({
"name": "projects/p/locations/l/ragCorpora/1/ragFiles/9",
"sizeBytes": 2048,
"ragFileType": "RAG_FILE_TYPE_PDF",
}))
.unwrap();
assert_eq!(file.size_bytes, Some(2048));
}
}