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:
8 files changed
+1295
-8
No files matched your search
@@ -10,6 +10,7 @@ use sha2::{Digest, Sha256};
|
||||
use crate::config::paths;
|
||||
|
||||
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
|
||||
const SBX_MIXIN_FILE_SUFFIX: &str = ".sbx-mixin.yaml";
|
||||
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -72,6 +73,13 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
|
||||
for path in collect_subdir_mixins(&paths::agents_data_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
// RAG sidecars are FLAT files named `<rag>.sbx-mixin.yaml` inside rags/, not
|
||||
// the `<subdir>/sbx-mixin.yaml` shape the two scans above walk. Loaded
|
||||
// unconditionally, mirroring agents/*: a RAG mixin only adds an outbound
|
||||
// allowlist entry for that RAG's host and opens no inbound rules.
|
||||
for path in collect_flat_mixins(&paths::rags_dir()) {
|
||||
out.push(read_mixin(path)?);
|
||||
}
|
||||
|
||||
if let Ok(cwd) = env::current_dir()
|
||||
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
|
||||
@@ -173,6 +181,30 @@ fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Mixins stored as flat `<name>.sbx-mixin.yaml` files directly inside `dir`,
|
||||
/// matched by suffix rather than by exact filename.
|
||||
fn collect_flat_mixins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let Ok(rd) = read_dir(dir) else { return result };
|
||||
|
||||
let mut entries: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.ends_with(SBX_MIXIN_FILE_SUFFIX))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
result.push(entry.path());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -439,4 +471,54 @@ network:
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// RAG sidecars are flat `<name>.sbx-mixin.yaml` files, matched by SUFFIX.
|
||||
#[test]
|
||||
fn collect_flat_mixins_matches_rag_sidecars_by_suffix() {
|
||||
let root = unique_root("flat-mixins");
|
||||
fs::write(root.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
fs::write(root.join("alpha.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
// The RAGs themselves must not be picked up, only their sidecars.
|
||||
fs::write(root.join("company-docs.yaml"), "driver: qdrant\n").unwrap();
|
||||
fs::write(root.join("notes.yaml"), "driver: yaml\n").unwrap();
|
||||
// A directory whose name ends in the suffix is not a mixin file.
|
||||
fs::create_dir_all(root.join("decoy.sbx-mixin.yaml")).unwrap();
|
||||
|
||||
let found = collect_flat_mixins(&root);
|
||||
let names: Vec<_> = found
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_str().unwrap())
|
||||
.collect();
|
||||
// Sorted by file name, so the order is deterministic.
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["alpha.sbx-mixin.yaml", "company-docs.sbx-mixin.yaml"]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Why `collect_flat_mixins` had to be written: the existing collector walks
|
||||
/// SUBDIRECTORIES for a file named exactly `sbx-mixin.yaml`, so it cannot see
|
||||
/// a flat sidecar. If this ever starts finding them, the new collector is
|
||||
/// redundant — but until then, removing it silently drops every RAG mixin.
|
||||
#[test]
|
||||
fn collect_subdir_mixins_cannot_see_flat_rag_sidecars() {
|
||||
let root = unique_root("flat-vs-subdir");
|
||||
fs::write(root.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
|
||||
|
||||
assert!(collect_subdir_mixins(&root).is_empty());
|
||||
assert_eq!(collect_flat_mixins(&root).len(), 1);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_flat_mixins_tolerates_a_missing_directory() {
|
||||
let root = unique_root("flat-missing");
|
||||
let absent = root.join("nope");
|
||||
assert!(collect_flat_mixins(&absent).is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user