Compare commits

..
Author SHA1 Message Date
Alex Clarke 2a40a5a81d Merge pull request #14 from Dark-Alex-17/feat/rag-driver-abstraction-v3
CI / All (ubuntu-latest) (push) Failing after 31s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
feat(rag): pluggable RagProvider abstraction with DuckDB and Qdrant drivers
2026-08-12 12:45:12 -06:00
Dark-Alex-17 ebba976a27 fmt: applied formatting 2026-08-12 12:07:38 -06:00
Dark-Alex-17 c84f9522e9 feat(rag): offer the storage driver when an agent initializes its RAG
Agent startup and graph rag nodes both run an interactive wizard when their
knowledge base has not been built, but neither offered the driver choice that
interactive named-RAG creation has, so both silently produced a yaml store.

A plain agent was the worse of the two: AgentConfig carries only documents, so
there was no way to get a duckdb RAG for one, interactively or declaratively. A
graph node could at least declare driver: in the workflow.

Agent startup now passes prompt_for_driver, and a rag node whose wizard runs is
asked too. The prompt is skipped when the node already declares a driver, and
sits inside the not-fully-specified branch after the non-interactive bail, so
declarative workflows and headless runs are unchanged. Temp RAGs still pass
false: they are deleted on the next run, so a persistent store would only leave
a sidecar behind.

The prompt moves to select_rag_driver rather than being duplicated.
2026-08-12 12:02:15 -06:00
Dark-Alex-17 81ed769f8a fix(rag): warn when a duckdb store is empty but files are indexed
A duckdb RAG is two files. The .yaml deliberately carries no vectors, and
open() runs CREATE TABLE IF NOT EXISTS, so a .yaml copied or synced without its
.duckdb sidecar produces a fresh empty store, hydrates to nothing, and answers
every query with nothing while .info rag still lists every indexed file.

Neither existing guard catches it: the anti-wipe check in rebuild_indexes needs
existing > 0, and the mandatory ? on hydration needs a genuine error, while an
absent store is the same Ok(empty) as a RAG with nothing indexed yet.

Warn rather than bail, so a store deleted on purpose still loads and can be
rebuilt.
2026-08-12 11:26:16 -06:00
Dark-Alex-17 6f7defe25f style: removed redundant comment 2026-08-12 10:56:34 -06:00
Dark-Alex-17 b837f82d7e fix(rag): keep a local Qdrant off an ambient proxy
Reverts the global proxy rework in 54685be and narrows it to the provider.

That commit took over proxy detection for every client in order to exempt
loopback and private ranges. Too broad: reqwest's detection also reads macOS
System Settings and the Windows registry behind its system-proxy feature, which
sits in its default set. Coyote disables default features today, so hand-rolling
the environment lookup happened to match — but re-enabling defaults later would
silently restore that support for main and not for the hand-rolled version. It
also made an explicitly configured proxy skip local hosts, which nobody asked
for: a proxy named for a LAN endpoint should be used.

build_client and utils are byte-identical to main again. The bypass now lives in
QdrantProvider::make_client, which is the only place that knows the target host,
and applies solely when that host is loopback, link-local, private or .local. A
public or cloud-hosted store keeps whatever the environment configures.

Also drops apply_proxy: with build_client reverted there was one caller left, and
set_proxy already covers it.

Both #[ignore]d live tests still pass against a Qdrant on loopback while an
ambient proxy that rejects it is in force.
2026-08-12 10:52:31 -06:00
Dark-Alex-17 54685be9a2 fix: keep loopback and LAN traffic off an ambient proxy
Pre-existing on main, not introduced by the driver work, but it makes a local
RAG backend unusable so it belongs with this change.

build_client only called set_proxy when a client had configured one of its own.
With nothing configured, reqwest's own detection applied, which sends every
request through a *_PROXY variable including ones bound for 127.0.0.1 or a LAN
address. A proxy cannot usefully forward those, and anything that intercepts
proxied traffic answers on behalf of a service that is running perfectly well,
so the error names the proxy rather than the store and reads as a Coyote fault.

Concretely, an installed Socket Firewall exports HTTP_PROXY to the processes it
wraps and rejects hosts outside its allow list. That turned a healthy Ollama on
the LAN into 'error decoding response body: expected value at line 2 column 1' —
its HTML refusal page parsed as JSON — and a loopback Qdrant into an HTTP 405.

Proxy handling is now always applied and always exempts loopback and private
ranges, with NO_PROXY merged in since replacing reqwest's detection also
replaces its handling of that variable. HTTP_PROXY and HTTPS_PROXY are kept
separate because they are allowed to differ. An explicitly configured proxy
still wins, and '-' still means none.

This also supersedes the unconditional no_proxy() added to the Qdrant client in
af9622d: that made it the only client to ignore a proxy outright, on a
justification I got wrong. It now shares this path, so a remote store behind a
real proxy keeps working.
2026-08-12 10:33:03 -06:00
Dark-Alex-17 4dd6e794b2 docs: removed redundant comment 2026-08-11 22:19:14 -06:00
Dark-Alex-17 af9622d31c fix(rag): stop routing Qdrant requests through an ambient proxy
make_client used a bare reqwest builder, which honours whatever proxy the
environment advertises. That made it the only HTTP client in Coyote to do so:
utils::set_proxy discards ambient settings and applies only Coyote's configured
proxy, and every other client goes through it.

The symptom is that a perfectly healthy Qdrant is unreachable and the error
belongs to the interposing proxy, not the store, so it reads as a Coyote or
Qdrant fault. Locally an installed Socket Firewall answered `.rag attach`
against 127.0.0.1:6333 with an HTML 'Connection Required' page and HTTP 405.

Both #[ignore]d live tests now pass against a real Qdrant; they failed with that
same 405 before this change, which is the first time either has run green.

A remote store that genuinely needs Coyote's configured proxy is a follow-up:
that means threading the proxy config into the provider.
2026-08-11 22:15:04 -06:00
Dark-Alex-17 6f586bd535 style: cleanup 2026-08-11 22:07:35 -06:00
Dark-Alex-17 78740db170 chore: ignore the .coyote workspace directory
It holds generated workspace state and should never be committed.
2026-08-11 22:03:45 -06:00
Dark-Alex-17 64d594f4ee refactor(rag): discover driver_config secrets by grammar, not field name
Sandbox provisioning only ever looked at driver_config["api_key"], so a driver
whose credential is called anything else would have been silently unprovisioned
inside a sandbox. It now scans every driver_config value and treats any that is
a secret placeholder as a credential, which is the same rule resolve_driver_config
already used at point of use.

The first one binds to the RAG's own service id, which is what the generated
mixin declares; any others register under their own names, as MCP secrets do.
The mixin still carries a single credential entry, so a driver needing two bound
secrets remains a follow-up.

Also drops the placeholder parser added in 74bc613. crate::vault::SECRET_RE is
already the canonical definition and was already imported here, so that was a
third implementation of the same grammar. Requiring the whole value to match is
what keeps a literal key from being read as a secret name and printed.

The api_key check is gone from RagData::validate: a generic config validator
should not know a provider's field names.
2026-08-11 22:03:45 -06:00
Dark-Alex-17 1322d73c7b style: further cleanup 2026-08-11 21:50:46 -06:00
Dark-Alex-17 de91ffa517 fix(rag): treat a zero min_score as no floor on Qdrant searches
parse_search_hits filtered on score > min_score, and the only caller passes
0.0. Qdrant Euclid collections score by negative distance, so every hit was
dropped and an attached Euclid collection returned nothing at all, silently.

This is the same trap the surrounding code already documents: score_threshold
is deliberately not sent because it is metric-aware and a 0.0 floor filters
everything out on Euclid. The local filter then reproduced it exactly. Only a
positive floor is now treated as a floor.
2026-08-11 21:07:20 -06:00
Dark-Alex-17 74bc613d94 fix(rag): address Copilot review findings on the driver abstraction
Five review comments, all real:

- hybrid_search ran its vector and keyword legs sequentially after the
  provider refactor; main ran them under tokio::join!. Restores the
  concurrency while keeping the degrade-on-error keyword behaviour, so a
  remote provider no longer pays two serial round trips per query.

- inject_rag_secrets derived a vault secret name by trimming braces, which
  leaves a literal key untouched. A RAG holding a plaintext api_key therefore
  looked the secret up by its own value and printed it to stderr on failure.
  Parsing is now strict and a non-placeholder is skipped with a warning that
  names no credential.

- validate() now refuses a driver_config.api_key that is not a {{NAME}}
  placeholder, so a plaintext key cannot reach the RAG YAML at all.

- Rag::create's catch-all arm treated any unrecognised driver as yaml. A typo
  built a yaml store, paid to embed the corpus, persisted the bad driver and
  only failed on the next run. Unknown drivers now fail immediately.

- The qdrant arm's error was written for a developer; it now tells the user
  that only attached collections are readable and points at .rag attach.
2026-08-11 21:04:21 -06:00
Dark-Alex-17 6d0a5550fe style: Removed some redundant comments 2026-08-11 20:56:51 -06:00
Dark-Alex-17 7b1c0342b4 fix(rag): delete the DuckDB write-ahead log alongside the store
Deleting a RAG removed its .duckdb file but left the sibling .duckdb.wal
behind. DuckDB only removes that log on a clean close, so any RAG whose
process was killed left one on disk, and creating a new RAG under the same
name let it inherit a write-ahead log describing someone else's data.

The test helper already cleaned the log up after itself, which is why no
test noticed the production path did not.
2026-08-11 16:55:19 -06:00
6 changed files with 278 additions and 69 deletions
+1
View File
@@ -5,3 +5,4 @@
.idea/ .idea/
/coyote.iml /coyote.iml
/.idea/ /.idea/
.coyote
+8 -3
View File
@@ -4,6 +4,7 @@ use crate::{
client::Model, client::Model,
config::memory, config::memory,
function::{Functions, run_llm_function}, function::{Functions, run_llm_function},
graph, rag,
}; };
use super::rag_cache::RagKey; use super::rag_cache::RagKey;
@@ -185,7 +186,7 @@ impl Agent {
&rag_path_clone, &rag_path_clone,
&document_paths, &document_paths,
abort, abort,
false, true,
) )
.await .await
}) })
@@ -1021,11 +1022,11 @@ async fn init_graph_rags(
// Graph validation catches this too, but it is skipped when // Graph validation catches this too, but it is skipped when
// `validate_before_run` is off, so this guard is the load-bearing one. // `validate_before_run` is off, so this guard is the load-bearing one.
if let Some(driver) = &rag_node.driver if let Some(driver) = &rag_node.driver
&& let Some(message) = crate::graph::validator::rag_driver_error(driver) && let Some(message) = graph::validator::rag_driver_error(driver)
{ {
bail!("rag node '{node_id}': {message}"); bail!("rag node '{node_id}': {message}");
} }
let config = rag_init_config(rag_node); let mut config = rag_init_config(rag_node);
let fully_specified = config.embedding_model.is_some() let fully_specified = config.embedding_model.is_some()
&& config.chunk_size.is_some() && config.chunk_size.is_some()
&& config.chunk_overlap.is_some(); && config.chunk_overlap.is_some();
@@ -1051,6 +1052,10 @@ async fn init_graph_rags(
initialized. RAG initialization is required for this agent." initialized. RAG initialization is required for this agent."
); );
} }
if config.driver.is_none() {
config.driver = Some(rag::select_rag_driver()?);
}
} }
let document_paths = let document_paths =
+8 -1
View File
@@ -437,6 +437,10 @@ pub(crate) fn remove_rag_sidecars(dir: &Path, name: &str) -> Result<()> {
if duckdb_path.exists() { if duckdb_path.exists() {
let _ = remove_file(&duckdb_path); let _ = remove_file(&duckdb_path);
} }
let wal_path = dir.join(format!("{name}.duckdb.wal"));
if wal_path.exists() {
let _ = remove_file(&wal_path);
}
let mixin_path = dir.join(format!("{name}.sbx-mixin.yaml")); let mixin_path = dir.join(format!("{name}.sbx-mixin.yaml"));
if mixin_path.exists() { if mixin_path.exists() {
remove_file(&mixin_path).with_context(|| { remove_file(&mixin_path).with_context(|| {
@@ -894,16 +898,19 @@ mod tests {
} }
#[test] #[test]
fn remove_rag_sidecars_removes_both() { fn remove_rag_sidecars_removes_duckdb_wal_and_mixin() {
let root = sidecar_temp_dir("rag-sidecars-both"); let root = sidecar_temp_dir("rag-sidecars-both");
let duckdb = root.join("docs.duckdb"); let duckdb = root.join("docs.duckdb");
let wal = root.join("docs.duckdb.wal");
let mixin = root.join("docs.sbx-mixin.yaml"); let mixin = root.join("docs.sbx-mixin.yaml");
fs::write(&duckdb, "db").unwrap(); fs::write(&duckdb, "db").unwrap();
fs::write(&wal, "wal").unwrap();
fs::write(&mixin, "mixin").unwrap(); fs::write(&mixin, "mixin").unwrap();
remove_rag_sidecars(&root, "docs").unwrap(); remove_rag_sidecars(&root, "docs").unwrap();
assert!(!duckdb.exists(), "the .duckdb sidecar must be removed"); assert!(!duckdb.exists(), "the .duckdb sidecar must be removed");
assert!(!wal.exists(), "the .duckdb.wal sidecar must be removed");
assert!( assert!(
!mixin.exists(), !mixin.exists(),
"the .sbx-mixin.yaml sidecar must be removed" "the .sbx-mixin.yaml sidecar must be removed"
+59 -34
View File
@@ -267,31 +267,10 @@ impl Rag {
} }
println!("⚙ Initializing RAG..."); println!("⚙ Initializing RAG...");
let (embedding_model, chunk_size, chunk_overlap) = Self::create_config(app)?; let (embedding_model, chunk_size, chunk_overlap) = Self::create_config(app)?;
// Only interactive named-RAG creation offers a driver choice. Temp RAGs and
// agent startup pass `false`; an explicit flag is used rather than inferring
// from the name because the agent path passes the literal name "rag", which is
// indistinguishable from a user creating a RAG genuinely named `rag`.
let driver = if prompt_for_driver { let driver = if prompt_for_driver {
let options = vec![ select_rag_driver()?
"yaml — portable, in-memory HNSW; usable from several Coyote processes at once (default)",
"duckdb — persistent on-disk store; vectors and content survive restarts; HNSW approximate search.",
];
let sel = Select::new("RAG storage driver:", options)
.with_starting_cursor(0)
.prompt()?;
if sel.starts_with("duckdb") {
println!(
"Note: several Coyote processes can query a duckdb RAG at the same time, \
but while one process is ingesting or rebuilding it the others cannot \
read it until that finishes. Changing its driver later means deleting \
and recreating the RAG."
);
"duckdb"
} else {
"yaml"
}
} else { } else {
"yaml" "yaml".to_string()
}; };
let reranker_model = app.rag_reranker_model.clone(); let reranker_model = app.rag_reranker_model.clone();
let top_k = app.rag_top_k; let top_k = app.rag_top_k;
@@ -318,7 +297,7 @@ impl Rag {
graph_hops: Some(graph_hops), graph_hops: Some(graph_hops),
}, },
); );
data.driver = driver.to_string(); data.driver = driver;
let mut rag = Self::create(app, name, save_path, data)?; let mut rag = Self::create(app, name, save_path, data)?;
let mut paths = doc_paths.to_vec(); let mut paths = doc_paths.to_vec();
if paths.is_empty() { if paths.is_empty() {
@@ -586,19 +565,40 @@ impl Rag {
if data.vectors.is_empty() { if data.vectors.is_empty() {
data.vectors = duck.read_all_vectors()?; data.vectors = duck.read_all_vectors()?;
} }
if data.vectors.is_empty() && !data.files.is_empty() {
println!(
"{} RAG '{name}' lists {} indexed file(s), but its vector store \
'{}' holds no vectors, so every search will return nothing. A \
duckdb RAG is two files: bring the .duckdb sidecar along with \
the .yaml, or re-embed with `.rebuild rag`.",
warning_text("WARNING:"),
data.files.len(),
db_path.display()
);
}
// data.files is always populated for duckdb, so build_bm25() is the only // data.files is always populated for duckdb, so build_bm25() is the only
// path; there is no from-DuckDB fallback. // path; there is no from-DuckDB fallback.
let bm25 = data.build_bm25(); let bm25 = data.build_bm25();
(Box::new(duck), bm25) (Box::new(duck), bm25)
} }
"qdrant" => bail!( "qdrant" => bail!(
"Qdrant RAGs cannot be constructed via Rag::create(); \ "RAG '{name}' uses driver 'qdrant' without `attached: true`. \
use Rag::attach() or Rag::load_async() instead" Coyote can currently only READ a pre-existing Qdrant \
collection — attach one with `.rag attach`. Writing to a \
Coyote-owned Qdrant collection is not supported yet."
), ),
_ => { "yaml" => {
let bm25 = data.build_bm25(); let bm25 = data.build_bm25();
(Box::new(YamlProvider::from_data(&data)), bm25) (Box::new(YamlProvider::from_data(&data)), bm25)
} }
// Explicitly NOT a catch-all falling through to yaml. A typo'd driver
// used to build a yaml store, pay to embed the whole corpus, persist
// the bad driver, and only fail on the NEXT run, leaving the RAG
// unusable without hand-editing the YAML.
other => bail!(
"Unknown RAG driver '{other}' for RAG '{name}'. \
Valid drivers: yaml, duckdb, qdrant."
),
}; };
let node_to_docs = data.knowledge_graph.build_node_to_docs(); let node_to_docs = data.knowledge_graph.build_node_to_docs();
let embedding_model = let embedding_model =
@@ -1169,12 +1169,7 @@ impl Rag {
top_k: usize, top_k: usize,
rerank_model: Option<&str>, rerank_model: Option<&str>,
) -> Result<Vec<(DocumentId, String)>> { ) -> Result<Vec<(DocumentId, String)>> {
let vector_search_results = self.vector_search(query, top_k, 0.0).await?; let keyword_leg = async {
debug!("vector_search_results: {vector_search_results:?}",);
let vector_search_ids: Vec<DocumentId> =
vector_search_results.into_iter().map(|(v, _)| v).collect();
let keyword_search_results: Vec<(DocumentId, f32)> =
if self.provider.has_native_keyword_search() { if self.provider.has_native_keyword_search() {
self.provider self.provider
.keyword_search(query, top_k) .keyword_search(query, top_k)
@@ -1185,7 +1180,16 @@ impl Rag {
}) })
} else { } else {
self.keyword_search(query, top_k, 0.0) self.keyword_search(query, top_k, 0.0)
}; }
};
let (vector_search_results, keyword_search_results) =
tokio::join!(self.vector_search(query, top_k, 0.0), keyword_leg);
let vector_search_results = vector_search_results?;
debug!("vector_search_results: {vector_search_results:?}",);
let vector_search_ids: Vec<DocumentId> =
vector_search_results.into_iter().map(|(v, _)| v).collect();
debug!("keyword_search_results: {keyword_search_results:?}",); debug!("keyword_search_results: {keyword_search_results:?}",);
let keyword_search_ids: Vec<DocumentId> = let keyword_search_ids: Vec<DocumentId> =
keyword_search_results.into_iter().map(|(v, _)| v).collect(); keyword_search_results.into_iter().map(|(v, _)| v).collect();
@@ -1842,6 +1846,27 @@ fn select_embedding_model(models: &[&Model]) -> Result<String> {
Ok(result.value) Ok(result.value)
} }
pub(crate) fn select_rag_driver() -> Result<String> {
let options = vec![
"yaml — portable, in-memory HNSW; usable from several Coyote processes at once (default)",
"duckdb — persistent on-disk store; vectors and content survive restarts; HNSW approximate search.",
];
let sel = Select::new("RAG storage driver:", options)
.with_starting_cursor(0)
.prompt()?;
if sel.starts_with("duckdb") {
println!(
"Note: several Coyote processes can query a duckdb RAG at the same time, \
but while one process is ingesting or rebuilding it the others cannot \
read it until that finishes. Changing its driver later means deleting \
and recreating the RAG."
);
Ok("duckdb".to_string())
} else {
Ok("yaml".to_string())
}
}
const EXTRACTOR_SKIP: &str = "Skip"; const EXTRACTOR_SKIP: &str = "Skip";
fn select_extractor_model(app: &AppConfig) -> Result<Option<String>> { fn select_extractor_model(app: &AppConfig) -> Result<Option<String>> {
+96 -11
View File
@@ -9,6 +9,7 @@ use reqwest::{Client, Response, StatusCode};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use url::{Host, Url};
/// Marks a `DocumentId` that stands in for a point id Coyote cannot carry /// Marks a `DocumentId` that stands in for a point id Coyote cannot carry
/// directly. Qdrant accepts UUID strings as point ids, and that is what /// directly. Qdrant accepts UUID strings as point ids, and that is what
@@ -104,7 +105,7 @@ fn parse_search_hits(
let score = pt["score"].as_f64()? as f32; let score = pt["score"].as_f64()? as f32;
Some((interner.document_id(&pt["id"])?, score)) Some((interner.document_id(&pt["id"])?, score))
}) })
.filter(|(_, score)| *score > min_score) .filter(|(_, score)| min_score <= 0.0 || *score > min_score)
.collect()) .collect())
} }
@@ -183,7 +184,22 @@ pub struct QdrantProvider {
} }
impl QdrantProvider { impl QdrantProvider {
fn make_client(api_key: Option<&str>) -> Result<Client> { fn skips_proxy(base_url: &str) -> bool {
let Ok(url) = Url::parse(base_url) else {
return false;
};
match url.host() {
Some(Host::Domain(name)) => {
name == "localhost" || name.ends_with(".localhost") || name.ends_with(".local")
}
Some(Host::Ipv4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
// No stable is_unique_local, so fc00::/7 is matched directly.
Some(Host::Ipv6(ip)) => ip.is_loopback() || ip.segments()[0] & 0xfe00 == 0xfc00,
None => false,
}
}
fn make_client(base_url: &str, api_key: Option<&str>) -> Result<Client> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if let Some(key) = api_key { if let Some(key) = api_key {
let mut value = let mut value =
@@ -191,10 +207,11 @@ impl QdrantProvider {
value.set_sensitive(true); value.set_sensitive(true);
headers.insert("api-key", value); headers.insert("api-key", value);
} }
Client::builder() let mut builder = Client::builder().default_headers(headers);
.default_headers(headers) if Self::skips_proxy(base_url) {
.build() builder = builder.no_proxy();
.context("Failed to build reqwest client") }
builder.build().context("Failed to build reqwest client")
} }
pub(crate) fn normalize_base_url(host: &str) -> String { pub(crate) fn normalize_base_url(host: &str) -> String {
@@ -219,7 +236,7 @@ impl QdrantProvider {
api_key: Option<&str>, api_key: Option<&str>,
) -> Result<Value> { ) -> Result<Value> {
let base_url = Self::normalize_base_url(host); let base_url = Self::normalize_base_url(host);
let client = Self::make_client(api_key)?; let client = Self::make_client(&base_url, api_key)?;
let resp = client let resp = client
.get(format!("{base_url}/collections/{collection}")) .get(format!("{base_url}/collections/{collection}"))
.send() .send()
@@ -237,7 +254,7 @@ impl QdrantProvider {
pub async fn new(host: &str, collection: &str, api_key: Option<&str>) -> Result<Self> { pub async fn new(host: &str, collection: &str, api_key: Option<&str>) -> Result<Self> {
let base_url = Self::normalize_base_url(host); let base_url = Self::normalize_base_url(host);
let client = Self::make_client(api_key)?; let client = Self::make_client(&base_url, api_key)?;
let resp = client let resp = client
.get(format!("{base_url}/collections/{collection}")) .get(format!("{base_url}/collections/{collection}"))
.send() .send()
@@ -260,7 +277,7 @@ impl QdrantProvider {
pub async fn list_collections(host: &str, api_key: Option<&str>) -> Result<Vec<String>> { pub async fn list_collections(host: &str, api_key: Option<&str>) -> Result<Vec<String>> {
let base_url = Self::normalize_base_url(host); let base_url = Self::normalize_base_url(host);
let client = Self::make_client(api_key)?; let client = Self::make_client(&base_url, api_key)?;
let resp = client let resp = client
.get(format!("{base_url}/collections")) .get(format!("{base_url}/collections"))
.send() .send()
@@ -310,7 +327,7 @@ impl QdrantProvider {
api_key: Option<&str>, api_key: Option<&str>,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let base_url = Self::normalize_base_url(host); let base_url = Self::normalize_base_url(host);
let client = Self::make_client(api_key)?; let client = Self::make_client(&base_url, api_key)?;
let url = format!("{base_url}/collections/{collection}/points/scroll"); let url = format!("{base_url}/collections/{collection}/points/scroll");
let body = serde_json::json!({ "limit": 1, "with_payload": false }); let body = serde_json::json!({ "limit": 1, "with_payload": false });
@@ -353,7 +370,8 @@ impl RagProvider for QdrantProvider {
// `score_threshold` is deliberately NOT sent. It is metric-aware: on Cosine // `score_threshold` is deliberately NOT sent. It is metric-aware: on Cosine
// collections 0.0 means "no floor" as expected, but Euclid collections score // collections 0.0 means "no floor" as expected, but Euclid collections score
// by negative distance, where 0.0 filters everything out. The attach wizard // by negative distance, where 0.0 filters everything out. The attach wizard
// does not pin the distance metric, so filter locally instead. // does not pin the distance metric, so filter locally instead; i.e. where a
// 0.0 floor is correctly treated as "no floor" (see `parse_search_hits`).
let body = serde_json::json!({ let body = serde_json::json!({
"vector": embedding, "vector": embedding,
"limit": top_k, "limit": top_k,
@@ -576,6 +594,73 @@ mod tests {
assert!(provider.fetch_content(&[]).await.unwrap().is_empty()); assert!(provider.fetch_content(&[]).await.unwrap().is_empty());
} }
#[test]
fn local_and_private_hosts_skip_the_proxy() {
for host in [
"http://localhost:6333",
"http://127.0.0.1:6333",
"http://192.168.0.56:6333",
"http://10.1.2.3:6333",
"http://172.16.4.5:6333",
"http://qdrant.local:6333",
"http://[::1]:6333",
] {
assert!(
QdrantProvider::skips_proxy(host),
"{host} should not be proxied"
);
}
}
#[test]
fn public_hosts_still_honour_the_environment() {
for host in [
"https://qdrant.example.com",
"http://8.8.8.8:6333",
"https://xyz.eu-central.aws.cloud.qdrant.io:6333",
"http://172.32.0.1:6333",
] {
assert!(
!QdrantProvider::skips_proxy(host),
"{host} must keep the environment's proxy"
);
}
}
/// Euclid collections score by NEGATIVE distance, so the 0.0 the caller
/// passes must mean "no floor". Filtering on it drops every hit — the exact
/// bug that keeps Qdrant's own `score_threshold` off the wire.
#[test]
fn a_zero_floor_keeps_negative_euclid_scores() {
let mut interner = PointIdInterner::default();
let search = serde_json::json!({
"result": [
{"id": 1, "score": -0.12},
{"id": 2, "score": -8.5},
]
});
let hits = parse_search_hits(&mut interner, &search, 0.0).unwrap();
assert_eq!(hits.len(), 2, "a 0.0 floor must not drop negative scores");
}
#[test]
fn a_positive_floor_still_filters() {
let mut interner = PointIdInterner::default();
let search = serde_json::json!({
"result": [
{"id": 1, "score": 0.9},
{"id": 2, "score": 0.2},
]
});
let hits = parse_search_hits(&mut interner, &search, 0.5).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0, DocumentId(1));
}
/// A UUID-keyed collection has to survive the whole `vector_search` → /// A UUID-keyed collection has to survive the whole `vector_search` →
/// `fetch_content` round trip, and the fetch must ask Qdrant for the ORIGINAL /// `fetch_content` round trip, and the fetch must ask Qdrant for the ORIGINAL
/// string id. Parsing ids with `as_u64()` used to drop these hits inside a /// string id. Parsing ids with `as_u64()` used to drop these hits inside a
+106 -20
View File
@@ -337,29 +337,20 @@ fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
if !data.attached { if !data.attached {
continue; continue;
} }
let Some(placeholder) = data.driver_config.get("api_key") else { let secret_names = driver_config_secret_names(&data);
let Some((primary, extra)) = secret_names.split_first() else {
continue; continue;
}; };
let service_id = mcp_credentials::secret_service_id(&stem);
if service_id.is_empty() || registered.contains(&service_id) {
continue;
}
let secret_name = placeholder
.trim_start_matches("{{")
.trim_end_matches("}}")
.trim();
match vault.get_secret(secret_name, false) { let service_id = mcp_credentials::secret_service_id(&stem);
Ok(secret_value) => { if !service_id.is_empty() && !registered.contains(&service_id) {
sbx_secret_set(&service_id, &secret_value) bind_rag_secret(vault, &service_id, primary, &stem)?;
.context("Failed to register RAG secret with sbx")?; }
}
Err(e) => { for name in extra {
eprintln!( let id = mcp_credentials::secret_service_id(name);
"Warning: could not load secret '{secret_name}' for RAG '{stem}': {e}. \ if !id.is_empty() && !registered.contains(&id) {
Queries to this RAG will fail inside the sandbox. \ bind_rag_secret(vault, &id, name, &stem)?;
Run `coyote --add-secret {secret_name}` to fix."
);
} }
} }
} }
@@ -367,6 +358,43 @@ fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
Ok(()) Ok(())
} }
fn driver_config_secret_names(data: &RagData) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for value in data.driver_config.values() {
let trimmed = value.trim();
let Ok(Some(caps)) = SECRET_RE.captures(trimmed) else {
continue;
};
if caps.get(0).map(|m| m.as_str()) != Some(trimmed) {
continue;
}
let Some(name) = caps.get(1).map(|m| m.as_str().trim()) else {
continue;
};
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
fn bind_rag_secret(vault: &Vault, service_id: &str, secret_name: &str, stem: &str) -> Result<()> {
match vault.get_secret(secret_name, false) {
Ok(secret_value) => {
sbx_secret_set(service_id, &secret_value)
.context("Failed to register RAG secret with sbx")?;
}
Err(e) => {
eprintln!(
"Warning: could not load secret '{secret_name}' for RAG '{stem}': {e}. \
Queries to this RAG will fail inside the sandbox. \
Run `coyote --add-secret {secret_name}` to fix."
);
}
}
Ok(())
}
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String { fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
match provider_type { match provider_type {
"claude" => "anthropic".to_string(), "claude" => "anthropic".to_string(),
@@ -646,6 +674,64 @@ fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn rag_with(driver_config: &[(&str, &str)]) -> RagData {
let mut data = RagData::new("m".into(), 1024, 50, None, 5, None, Default::default());
data.driver = "qdrant".to_string();
data.attached = true;
for (k, v) in driver_config {
data.driver_config.insert(k.to_string(), v.to_string());
}
data
}
#[test]
fn secret_names_are_found_whatever_the_field_is_called() {
let data = rag_with(&[
("host", "qdrant.example.com:6333"),
("collection", "docs"),
("token", "{{SOME_TOKEN}}"),
]);
assert_eq!(driver_config_secret_names(&data), vec!["SOME_TOKEN"]);
}
#[test]
fn a_literal_credential_is_not_treated_as_a_secret_name() {
let data = rag_with(&[("api_key", "sk-a-real-looking-key")]);
assert!(driver_config_secret_names(&data).is_empty());
}
#[test]
fn plain_values_are_never_mistaken_for_secrets() {
let data = rag_with(&[("host", "localhost:6333"), ("collection", "docs")]);
assert!(driver_config_secret_names(&data).is_empty());
}
#[test]
fn a_partial_placeholder_is_not_a_credential() {
let data = rag_with(&[("api_key", "Bearer {{KEY}}")]);
assert!(driver_config_secret_names(&data).is_empty());
}
#[test]
fn several_secrets_are_all_found_and_deduped() {
let data = rag_with(&[
("api_key", "{{QDRANT_KEY}}"),
("host", "localhost:6333"),
("token", "{{ OTHER_TOKEN }}"),
("fallback_key", "{{QDRANT_KEY}}"),
]);
assert_eq!(
driver_config_secret_names(&data),
vec!["QDRANT_KEY", "OTHER_TOKEN"],
"order follows driver_config, and a repeat is not registered twice"
);
}
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]