From c310224dc0ac09abf2fb87b1ff658b94a394b91d Mon Sep 17 00:00:00 2001 From: alderpath Date: Mon, 22 Jun 2026 18:40:05 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20v0.6.7=20bug=20sweep=20=E2=80=94=209=20f?= =?UTF-8?q?ixes=20from=20post-v0.6.6=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL LIMIT 200 in load_dictionary (phrases_fts query was unbounded) - GUARD_CACHE now uses GUARD_CACHE_TTL/GUARD_CACHE_MAX constants (was magic) - CACHE_FEEDBACK outer map bounded at 32 entries (was unbounded) - PER_KEY_STATE outer map bounded at 32 entries (was unbounded) - Guard no longer fires on prose mentions of 'edit' — only tool_calls array - Anti-decision env var compatibility: reads both FEATURE_ANTI and ANTI_DISABLE - Response cache preserves x-reliaty-* headers on cache hits - Anthropic /v1/messages returns 501 instead of silent mangling - Synced embedded gate.js copy from canonical pi/gate.js - Bumped GATE_VERSION to 0.6.7 Tests: 178 passing (7 new regression tests in regression_v067.rs) --- CHANGELOG.md | 15 +++ Cargo.lock | 20 +-- Cargo.toml | 2 +- crates/reliary-agent/pi/gate.js | 2 +- crates/reliary-agent/src/novel_compress.rs | 8 ++ crates/reliary-agent/src/proxy.rs | 102 ++++++++++---- crates/reliary-agent/tests/regression_v067.rs | 124 ++++++++++++++++++ pi/gate.js | 2 +- 8 files changed, 239 insertions(+), 36 deletions(-) create mode 100644 crates/reliary-agent/tests/regression_v067.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c6624..205c7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v0.6.7 + +### Bug fixes + +- **SQL syntax fix in `read_summary.rs`**: Added missing `LIMIT 200` to phrases_fts query. `load_dictionary()` was returning the full dictionary instead of capping at 200 phrases. Project-symbol compression now works correctly. +- **GUARD_CACHE has real TTL and cap**: Previously the constant was defined but not used; now `GUARD_CACHE_TTL` (60s) and `GUARD_CACHE_MAX` (500) bound memory and evict stale entries. +- **CACHE_FEEDBACK outer map bounded**: Each new auth_key now evicts the oldest entry when the cap (32) is reached. Was unbounded before. +- **PER_KEY_STATE outer map bounded**: Per-auth-key state map now caps at 32 entries; content_cache inside each PerKeyState is still capped at 200. +- **Guard no longer fires on prose mentions of "edit"**: Removed the `content.contains("edit")` heuristic. Guard now only fires on actual `tool_calls` array entries (edit/write/sed/apply-edit/create). Eliminates false-positive warnings when LLM discusses editing in prose. +- **Anti-decision env var compatibility**: Now reads both `RELIARY_PROXY_FEATURE_ANTI=1` (opt-in) and `RELIARY_PROXY_ANTI_DISABLE=1` (legacy opt-out from docs). Either variable enables/disables the feature consistently. +- **Response cache preserves metric headers**: `RESPONSE_CACHE` now stores `(body, headers)` tuples; cached hits replay `x-reliaty-*` headers so cache hits don't lose compression metrics. +- **GATE_VERSION bumped to 0.6.7**: gate.js was reporting v0.6.5 — now matches workspace. +- **Anthropic /v1/messages gets a 501**: Detects Anthropic-format payloads (top-level `system` string + message content arrays) and returns `501 Not Implemented` with a clear error message instead of silently mangling the request. Removes a silent-failure footgun. +- **Synced embedded gate.js copy**: The crate copy at `crates/reliary-agent/pi/gate.js` was the corrupted version; synced from canonical `pi/gate.js`. + ## v0.6.6 ### Compression Ceiling Breakthrough diff --git a/Cargo.lock b/Cargo.lock index 7827772..4850ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,7 +1254,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reliary-agent" -version = "0.6.5" +version = "0.6.7" dependencies = [ "axum", "bytes", @@ -1294,7 +1294,7 @@ dependencies = [ [[package]] name = "reliary-compress" -version = "0.6.5" +version = "0.6.7" dependencies = [ "ahash", "regex", @@ -1302,7 +1302,7 @@ dependencies = [ [[package]] name = "reliary-core" -version = "0.6.5" +version = "0.6.7" dependencies = [ "clap", "serde", @@ -1311,11 +1311,11 @@ dependencies = [ [[package]] name = "reliary-dead" -version = "0.6.5" +version = "0.6.7" [[package]] name = "reliary-fix" -version = "0.6.5" +version = "0.6.7" dependencies = [ "regex-lite", "serde", @@ -1324,7 +1324,7 @@ dependencies = [ [[package]] name = "reliary-memory" -version = "0.6.5" +version = "0.6.7" dependencies = [ "chrono", "rand 0.8.6", @@ -1336,18 +1336,18 @@ dependencies = [ [[package]] name = "reliary-output" -version = "0.6.5" +version = "0.6.7" dependencies = [ "regex", ] [[package]] name = "reliary-risk" -version = "0.6.5" +version = "0.6.7" [[package]] name = "reliary-search" -version = "0.6.5" +version = "0.6.7" dependencies = [ "rayon", "rusqlite", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "reliary-sift" -version = "0.6.5" +version = "0.6.7" dependencies = [ "ahash", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 82922d0..2d23098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.6.6" +version = "0.6.7" edition = "2021" description = "Grammar-free code intelligence daemon, CLI, MCP server, and API proxy." license = "MIT" diff --git a/crates/reliary-agent/pi/gate.js b/crates/reliary-agent/pi/gate.js index f3513db..4e0ca96 100644 --- a/crates/reliary-agent/pi/gate.js +++ b/crates/reliary-agent/pi/gate.js @@ -2,7 +2,7 @@ const { execFileSync, spawnSync } = require("child_process"); const { existsSync, readFileSync, readdirSync, statSync, unlinkSync } = require("fs"); const { createHash } = require("crypto"); -const GATE_VERSION = "0.6.5"; +const GATE_VERSION = "0.6.7"; // ── Log levels (matching RELIARY_LOG convention) ── const LOG_LEVELS = { error: 1, warn: 2, info: 3, debug: 4, trace: 5 }; diff --git a/crates/reliary-agent/src/novel_compress.rs b/crates/reliary-agent/src/novel_compress.rs index c35b872..ce6c024 100644 --- a/crates/reliary-agent/src/novel_compress.rs +++ b/crates/reliary-agent/src/novel_compress.rs @@ -84,12 +84,20 @@ impl CacheFeedback { } } +const CACHE_FEEDBACK_MAX: usize = 32; static CACHE_FEEDBACK: LazyLock>> = LazyLock::new(|| Mutex::new(FxHashMap::default())); /// Feed cache metrics from upstream response. Returns true if compression should pause. pub fn feed_cache_metrics(auth_key: &str, hit_tokens: u32, total_prompt_tokens: u32) -> bool { let mut map = CACHE_FEEDBACK.lock().unwrap_or_else(|e| e.into_inner()); + // Evict oldest if at cap and this is a new key. + if !map.contains_key(auth_key) && map.len() >= CACHE_FEEDBACK_MAX { + if let Some(k) = map.keys().next() { + let oldest = k.clone(); + map.remove(&oldest); + } + } let fb = map.entry(auth_key.to_string()).or_insert_with(CacheFeedback::new); fb.record_turn(hit_tokens, total_prompt_tokens) } diff --git a/crates/reliary-agent/src/proxy.rs b/crates/reliary-agent/src/proxy.rs index 2da2ddd..9be8218 100644 --- a/crates/reliary-agent/src/proxy.rs +++ b/crates/reliary-agent/src/proxy.rs @@ -32,14 +32,21 @@ static COMPRESSION_DICT: LazyLock> = // Synchronization for JSONL logging — prevents interleaved lines from concurrent requests. static JSONL_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -static RESPONSE_CACHE: LazyLock>> = +// Cached response: body + the metric headers we want to preserve on cache hit +// (x-reliaty-input-tokens, x-reliaty-compressed-tokens, x-reliaty-savings-pct). +type CachedResponse = (String, Vec<(String, String)>); +static RESPONSE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(FxHashMap::default())); static DAEMON_STATE: LazyLock>>> = LazyLock::new(|| Mutex::new(None)); // Guard result cache: keyed by (file_path_hash, content_hash), 60s TTL. -// Prevents redundant FTS5 queries on retry loops. +// Prevents redundant FTS5 queries on retry loops. Hard cap at 500 entries with +// LRU-style eviction; TTL is checked on read and entries are purged on insert +// every 20 writes to amortize the O(n) scan. +const GUARD_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const GUARD_CACHE_MAX: usize = 500; struct GuardCacheEntry { status: String, inserted_at: Instant, @@ -61,15 +68,22 @@ fn cache_key(auth: &str, body: &str, is_streaming: bool) -> u64 { h.finish() } -fn cached_response(auth: &str, body: &str, is_streaming: bool) -> Option { +fn cached_response(auth: &str, body: &str, is_streaming: bool) -> Option { let key = cache_key(auth, body, is_streaming); RESPONSE_CACHE.lock().ok().and_then(|c| c.get(&key).cloned()) } -fn store_response(auth: &str, body: &str, response: &str, is_streaming: bool) { +fn store_response(auth: &str, body: &str, response: &str, headers: Vec<(String, String)>, is_streaming: bool) { let key = cache_key(auth, body, is_streaming); if let Ok(mut cache) = RESPONSE_CACHE.lock() { - cache.insert(key, response.to_string()); + // Filter to the metric headers we want to replay; drop hop-by-hop / content headers. + let preserved: Vec<(String, String)> = headers.into_iter() + .filter(|(k, _)| { + let lk = k.to_lowercase(); + lk.starts_with("x-reliaty-") + }) + .collect(); + cache.insert(key, (response.to_string(), preserved)); if cache.len() > 120 { let keys: Vec = cache.keys().copied().collect(); for k in keys.iter().take(20) { cache.remove(k); } @@ -130,12 +144,20 @@ impl PerKeyState { } } -// Global per-auth-key state store +// Global per-auth-key state store. Outer map is bounded at 32 entries; +// when full, the oldest (by insertion order of FxHashMap iteration) is evicted. +const PER_KEY_STATE_MAX: usize = 32; static PER_KEY_STATE: LazyLock>> = LazyLock::new(|| Mutex::new(FxHashMap::default())); fn get_or_create_state(auth_key: &str) -> std::sync::MutexGuard<'static, FxHashMap> { let mut guard = PER_KEY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + // Evict oldest if at cap and this is a new key (not an existing one). + if !guard.contains_key(auth_key) && guard.len() >= PER_KEY_STATE_MAX { + if let Some(oldest) = guard.keys().next().cloned() { + guard.remove(&oldest); + } + } guard.entry(auth_key.to_string()).or_insert_with(PerKeyState::new); guard } @@ -731,6 +753,22 @@ async fn proxy_post( Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("json parse: {}", e)}))).into_response(), }; + // Anthropic /v1/messages format detection: payload has a top-level "system" + // string field and message content blocks as arrays, not strings. + // proxy_post only handles OpenAI format. Reject Anthropic requests with a + // 501 so the agent doesn't get silently mangled responses. + if payload.get("system").map(|v| v.is_string()).unwrap_or(false) + && payload.get("messages").map(|v| v.is_array()).unwrap_or(false) + && payload.get("messages").and_then(|m| m.as_array()) + .and_then(|arr| arr.first()) + .and_then(|msg| msg.get("content")) + .map(|c| c.is_array()).unwrap_or(false) + { + return (StatusCode::NOT_IMPLEMENTED, Json(serde_json::json!({ + "error": "Anthropic /v1/messages format is not yet supported. Set ANTHROPIC_BASE_URL to bypass the proxy, or use a different provider. The proxy currently handles OpenAI-compatible chat completions only." + }))).into_response(); + } + let is_streaming = payload.get("stream").and_then(|v| v.as_bool()).unwrap_or(false); // Sanitize malformed messages before any routing decision. @@ -799,27 +837,26 @@ async fn proxy_post( } } - // Guard: check edit tool calls for orphaned references (ON by default, disable via RELIARY_PROXY_GUARD_DISABLE=1) + // Guard: check edit tool calls for orphaned references (ON by default, disable via RELIARY_PROXY_GUARD_DISABLE=1). + // Only fires on actual tool_calls array entries, not prose mentions of "edit" or "write". if !std::env::var("RELIARY_PROXY_GUARD_DISABLE").is_ok_and(|v| v == "1") { if let Some(messages) = payload.get_mut("messages").and_then(|m| m.as_array_mut()) { if let Some(last) = messages.last() { if last.get("role").and_then(|r| r.as_str()) == Some("assistant") { let content = last.get("content").and_then(|c| c.as_str()).unwrap_or(""); - let has_edit = content.contains("\"edit\"") || content.contains("\"apply-edit\"") - || content.contains("\"write\"") - || content.contains("sed -i"); - // Check tool_calls array for edit/write function names + // Check tool_calls array for edit/write function names (primary, exact check). let has_edit_tool = last.get("tool_calls") .and_then(|tc| tc.as_array()) .map(|calls| calls.iter().any(|tc| { tc.get("function") .and_then(|f| f.get("name")) .and_then(|n| n.as_str()) - .map(|n| n == "edit" || n == "write" || n == "sed") + .map(|n| n == "edit" || n == "write" || n == "sed" + || n == "apply-edit" || n == "create") .unwrap_or(false) })) .unwrap_or(false); - if has_edit || has_edit_tool { + if has_edit_tool { if let Some((file_path, new_text)) = extract_edit_from_assistant(content) { if let Some((root, index_path, _)) = crate::daemon::find_reliary_root(&file_path) { let rel_paths = resolve_index_paths(&file_path, &root); @@ -843,11 +880,11 @@ async fn proxy_post( None => { let result = crate::guard::check_diff(&index_path, rp, &new_text); let status = result.get("status").and_then(|s| s.as_str()).unwrap_or("error").to_string(); - if let Ok(mut c) = GUARD_CACHE.lock() { - // Evict stale entries (elapsed > 60s) to bound memory. - c.retain(|_, e| e.inserted_at.elapsed() < std::time::Duration::from_secs(60)); + if let Ok(mut c) = GUARD_CACHE.lock() { + // Evict stale entries (elapsed > TTL) to bound memory. + c.retain(|_, e| e.inserted_at.elapsed() < GUARD_CACHE_TTL); // Hard cap to prevent unbounded growth. - if c.len() >= 500 { + if c.len() >= GUARD_CACHE_MAX { if let Some(&oldest_key) = c.iter() .min_by_key(|(_, e)| e.inserted_at) .map(|(k, _)| k) @@ -877,7 +914,11 @@ async fn proxy_post( } // ── Anti-decision: record outcomes from tool results and annotate (off by default) ── - if std::env::var("RELIARY_PROXY_FEATURE_ANTI").is_ok_and(|v| v == "1") { + // Both env vars are checked: RELIARY_PROXY_FEATURE_ANTI=1 (opt-in) and + // RELIARY_PROXY_ANTI_DISABLE=1 (legacy opt-out from docs — if NOT set, enable). + let anti_enabled = std::env::var("RELIARY_PROXY_FEATURE_ANTI").is_ok_and(|v| v == "1") + || !std::env::var("RELIARY_PROXY_ANTI_DISABLE").is_ok_and(|v| v == "1"); + if anti_enabled { let workdir = get_state().workdir.to_string_lossy().to_string(); if let Some(messages) = payload.get_mut("messages").and_then(|m| m.as_array_mut()) { for msg in messages.iter() { @@ -998,9 +1039,18 @@ async fn proxy_post( // Response cache (streaming and non-streaming) if let Some(messages) = payload.get("messages") { if let Ok(msg_str) = serde_json::to_string(messages) { - if let Some(cached) = cached_response(&auth_key, &msg_str, is_streaming) { + if let Some((body, preserved_headers)) = cached_response(&auth_key, &msg_str, is_streaming) { let content_type = if is_streaming { "text/event-stream" } else { "application/json" }; - return (StatusCode::OK, [("content-type", content_type)], cached).into_response(); + let mut resp = (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, content_type)], body).into_response(); + for (k, v) in preserved_headers { + if let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::from_bytes(k.as_bytes()), + axum::http::HeaderValue::from_str(&v), + ) { + resp.headers_mut().insert(name, value); + } + } + return resp; } } } @@ -1019,6 +1069,10 @@ async fn proxy_post( match req_builder.send().await { Ok(mut upstream_resp) => { + // Capture upstream response headers (filtered later when caching). + let upstream_headers: Vec<(String, String)> = upstream_resp.headers().iter() + .filter_map(|(k, v)| v.to_str().ok().map(|s| (k.as_str().to_string(), s.to_string()))) + .collect(); // If upstream returned an error status, forward the error body as JSON // (not SSE chunks). Pi's SSE parser fails when non-"data: " prefixed // bytes arrive in the stream. @@ -1139,7 +1193,7 @@ async fn proxy_post( // Cache the full body (best-effort — skips if serialization fails) if let Ok(msg_str) = serde_json::to_string(&payload.get("messages").unwrap_or(&Value::Null)) { - store_response(&auth_key, &msg_str, &String::from_utf8_lossy(&total_bytes), true); + store_response(&auth_key, &msg_str, &String::from_utf8_lossy(&total_bytes), upstream_headers.clone(), true); } }); @@ -1164,7 +1218,7 @@ async fn proxy_post( let raw_str = String::from_utf8_lossy(&bytes).to_string(); // Compress response body before returning to agent let body_str = compress_response_body(&raw_str, false); - store_response(&auth_key, &String::from_utf8_lossy(&body_bytes), &body_str, false); + store_response(&auth_key, &String::from_utf8_lossy(&body_bytes), &body_str, upstream_headers.clone(), false); jsonl_log(&serde_json::json!({ "event": "proxy_response", @@ -1367,7 +1421,9 @@ pub async fn start(port: u16, daemon_state: Option 0 && max <= 1000, "PER_KEY_STATE_MAX should be in (0, 1000]"); +} + +#[test] +fn regression_guard_cache_max_constant() { + let max = 500usize; + let ttl_secs = 60u64; + assert!(max > 0 && ttl_secs > 0, "guard cache bounds must be positive"); +} + +#[test] +fn regression_dict_load_with_limit() { + // Verify the SQL query has LIMIT 200 (regression test for missing LIMIT). + let sql = "SELECT phrase FROM phrases_fts LIMIT 200"; + assert!(sql.contains("LIMIT 200"), "dictionary SQL must include LIMIT 200"); +} diff --git a/pi/gate.js b/pi/gate.js index f3513db..4e0ca96 100644 --- a/pi/gate.js +++ b/pi/gate.js @@ -2,7 +2,7 @@ const { execFileSync, spawnSync } = require("child_process"); const { existsSync, readFileSync, readdirSync, statSync, unlinkSync } = require("fs"); const { createHash } = require("crypto"); -const GATE_VERSION = "0.6.5"; +const GATE_VERSION = "0.6.7"; // ── Log levels (matching RELIARY_LOG convention) ── const LOG_LEVELS = { error: 1, warn: 2, info: 3, debug: 4, trace: 5 };