feat(rag): add attach-only Qdrant provider, attach wizard and sandbox wiring

Adds QdrantProvider as a read-only driver for pre-existing remote Qdrant
collections, an interactive '.rag attach' wizard, and the sandbox credential
and domain-whitelisting wiring that lets an attached RAG work inside a sandbox.

Attach-only by design: rebuild_indexes bails for both the attached and the
unattached case rather than silently succeeding. Coyote never writes to Qdrant
in this change.

Vectors are never hydrated back from Qdrant. Cosine collections L2-normalize
stored vectors on write, so reading them back returns unit-length copies of the
originals; the YAML vector copy is authoritative and the serialization guard
stays scoped to the duckdb driver alone.

Collections keyed by string or UUID point IDs are rejected at attach time. The
read path parses point ids as u64 inside a filter_map, so such a collection
would otherwise yield zero results with no error.

The three response-shape parsers are pure functions over an already-parsed JSON
body, unit-tested against captured fixtures, with the async wrappers delegating
to them rather than duplicating the logic.

'.rag' now splits its first argument, so '.rag attach <name>' no longer tries to
load a RAG literally named 'attach <name>'.
This commit is contained in:
2026-08-10 13:40:22 -06:00
parent 98d3ba4a83
commit 7a732436aa
8 changed files with 1295 additions and 8 deletions
+10 -2
View File
@@ -146,11 +146,18 @@ impl Agent {
let rag = if rag_path.exists() {
let key = RagKey::Agent(name.to_string());
let app_clone = app.clone();
let vault_clone = app_state.vault.clone();
let rag_path_clone = rag_path.clone();
let rag = app_state
.rag_cache
.load_with(key, || async move {
Rag::load(&app_clone, DEFAULT_AGENT_NAME, &rag_path_clone)
Rag::load_async(
&app_clone,
&vault_clone,
DEFAULT_AGENT_NAME,
&rag_path_clone,
)
.await
})
.await?;
Some(rag)
@@ -972,12 +979,13 @@ async fn init_graph_rags(
};
let rag = if rag_path.exists() {
let app_clone = app.clone();
let vault_clone = app_state.vault.clone();
let path_clone = rag_path.clone();
let name_clone = node_id.clone();
app_state
.rag_cache
.load_with(key, || async move {
Rag::load(&app_clone, &name_clone, &path_clone)
Rag::load_async(&app_clone, &vault_clone, &name_clone, &path_clone).await
})
.await?
} else {
+29 -1
View File
@@ -4122,6 +4122,9 @@ impl RequestContext {
}
let app = self.app.config.clone();
// Hoisted: `rag_cache` below borrows `self`, so the loader closure cannot
// reach through `self` for the vault. `GlobalVault` is an Arc, so this is cheap.
let vault = self.app.vault.clone();
let rag_cache = self.rag_cache();
let working_mode = self.working_mode;
@@ -4158,6 +4161,7 @@ impl RequestContext {
let loaded = rag_cache
.load_with(key.clone(), || {
let app = app.clone();
let vault = vault.clone();
let rag_path = rag_path.clone();
let abort_signal = abort_signal.clone();
async move {
@@ -4168,7 +4172,7 @@ impl RequestContext {
Rag::init(&app, name, &rag_path, &[], abort_signal.clone(), true)
.await
} else {
Rag::load(&app, name, &rag_path)
Rag::load_async(&app, &vault, name, &rag_path).await
}
}
})
@@ -4181,6 +4185,30 @@ impl RequestContext {
Ok(())
}
pub async fn attach_rag(&mut self, name: &str) -> Result<()> {
let rag_path = self.rag_file(name);
if rag_path.exists() {
bail!(
"RAG '{name}' already exists at '{}'. \
Use a different name, or delete the existing file first.",
rag_path.display()
);
}
let app = self.app.config.as_ref();
let vault = self.app.vault.clone();
let rag = Rag::attach(app, &vault, name, &rag_path).await?;
let rag = Arc::new(rag);
// Populate the cache so a later `.rag <name>` reuses this instance rather
// than re-running the network preflight. Attach is always a global RAG.
let key = RagKey::Named(name.to_string());
self.rag_cache().insert(key.clone(), &rag);
self.rag = Some(rag);
// Carried so invalidation in rebuild_rag()/edit_rag_docs() can find this
// entry; without it a stale Arc would linger in the cache all session.
self.rag_key = Some(key);
Ok(())
}
pub async fn edit_rag_docs(&mut self, abort_signal: AbortSignal) -> Result<()> {
let mut rag = match self.rag.clone() {
Some(v) => v.as_ref().clone(),