Phase 21 — Rust port of scratchpad + tree-split primitives (companion to
the 2026-04-21 TS shipment). New crates/aibridge modules:
context.rs — estimate_tokens (chars/4 ceil), context_window_for,
assert_context_budget returning a BudgetCheck with
numeric diagnostics on both success and overflow.
Windows table mirrors config/models.json.
continuation.rs — generate_continuable<G: TextGenerator>. Handles the
two failure modes: empty-response from thinking
models (geometric 2x budget backoff up to budget_cap)
and truncated-non-empty (continuation with partial
as scratchpad). is_structurally_complete balances
braces then JSON.parse-checks. Guards the degen case
"all retries empty, don't loop on empty partial".
tree_split.rs — generate_tree_split map->reduce with running
scratchpad. Per-shard + reduce-prompt go through
assert_context_budget first; loud-fails rather than
silently truncating. Oldest-digest-first scratchpad
truncation at scratchpad_budget (default 6000 t).
TextGenerator trait (native async-fn-in-trait, edition 2024). AiClient
implements it; ScriptedGenerator test double lets tests inject canned
sequences without a live Ollama.
GenerateRequest gained think: Option<bool> — forwards to sidecar for
per-call hidden-reasoning opt-out on hot-path JSON emitters. Three
existing callsites updated (rag.rs x2, service.rs hybrid answer).
Phase 27 — Playbook versioning. PlaybookEntry gained four optional
fields (all #[serde(default)] so pre-Phase-27 state loads as roots):
version u32, default 1
parent_id Option<String>, previous version's playbook_id
superseded_at Option<String>, set when newer version replaces
superseded_by Option<String>, the playbook_id that replaced
New methods:
revise_entry(parent_id, new_entry) — appends new version, stamps
superseded_at+superseded_by on parent, inherits parent_id and sets
version = parent + 1 on the new entry. Rejects revising a retired
or already-superseded parent (tip-of-chain is the only valid
revise target).
history(playbook_id) — returns full chain root->tip from any node.
Walks parent_id back to root, then superseded_by forward to tip.
Cycle-safe.
Superseded entries excluded from boost (same rule as retired): filter
in compute_boost_for_filtered_with_role (both active-entries prefilter
and geo-filtered path), rebuild_geo_index, and upsert_entry's existing-
idx search. status_counts returns (total, retired, superseded, failures);
/status JSON reports active = total - retired - superseded.
Endpoints:
POST /vectors/playbook_memory/revise
GET /vectors/playbook_memory/history/{id}
Doc-sync — PHASES.md + PRD.md drifted from git after Phases 24-26
shipped. Fixes applied:
- Phase 24 marked shipped (commit b95dd86) with detail of observer
HTTP ingest + scenario outcome streaming. PRD "NOT YET WIRED"
rewritten to reflect shipped state.
- Phase 25 (validity windows, commit e0a843d) added to PHASES +
PRD.
- Phase 26 (Mem0 upsert + Letta hot cache, commit 640db8c) added.
- Phase 27 entry added to both docs.
- Phase 19.6 time decay corrected: was documented as "deferred",
actually wired via BOOST_HALF_LIFE_DAYS = 30.0 in playbook_memory.rs.
- Phase E/Phase 8 tombstone-at-compaction limit note updated —
Phase E.2 closed it.
Tests: 8 new version_tests in vectord (chain-metadata stamping,
retired/superseded parent rejection, boost exclusion, history from
root/tip/middle, legacy default round-trip, status counts). 25 new
aibridge tests (context/continuation/tree_split). Workspace total
145 green (was 120).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.6 KiB
Rust
189 lines
6.6 KiB
Rust
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::Duration;
|
|
|
|
/// HTTP client for the Python AI sidecar.
|
|
#[derive(Clone)]
|
|
pub struct AiClient {
|
|
client: Client,
|
|
base_url: String,
|
|
}
|
|
|
|
// -- Request/Response types --
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct EmbedRequest {
|
|
pub texts: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
pub struct EmbedResponse {
|
|
pub embeddings: Vec<Vec<f64>>,
|
|
pub model: String,
|
|
pub dimensions: usize,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct GenerateRequest {
|
|
pub prompt: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub system: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<u32>,
|
|
/// Phase 21 — per-call opt-out of hidden reasoning. Thinking models
|
|
/// (qwen3.5, gpt-oss, etc) burn tokens on reasoning before the
|
|
/// visible response starts; setting this to `false` on hot-path
|
|
/// JSON emitters avoids empty returns when the budget is tight.
|
|
/// Sidecar forwards this to Ollama's `think` parameter; if the
|
|
/// sidecar drops an unknown field the request still succeeds.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub think: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
pub struct GenerateResponse {
|
|
pub text: String,
|
|
pub model: String,
|
|
pub tokens_evaluated: Option<u64>,
|
|
pub tokens_generated: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct RerankRequest {
|
|
pub query: String,
|
|
pub documents: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub top_k: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
pub struct ScoredDocument {
|
|
pub index: usize,
|
|
pub text: String,
|
|
pub score: f64,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone)]
|
|
pub struct RerankResponse {
|
|
pub results: Vec<ScoredDocument>,
|
|
pub model: String,
|
|
}
|
|
|
|
impl AiClient {
|
|
pub fn new(base_url: &str) -> Self {
|
|
let client = Client::builder()
|
|
.timeout(Duration::from_secs(120))
|
|
.build()
|
|
.expect("failed to build HTTP client");
|
|
Self {
|
|
client,
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
}
|
|
}
|
|
|
|
pub async fn health(&self) -> Result<serde_json::Value, String> {
|
|
let resp = self.client
|
|
.get(format!("{}/health", self.base_url))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("sidecar unreachable: {e}"))?;
|
|
resp.json().await.map_err(|e| format!("invalid response: {e}"))
|
|
}
|
|
|
|
pub async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
|
|
let resp = self.client
|
|
.post(format!("{}/embed", self.base_url))
|
|
.json(&req)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("embed request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("embed error ({}): {text}", text.len()));
|
|
}
|
|
resp.json().await.map_err(|e| format!("embed parse error: {e}"))
|
|
}
|
|
|
|
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
|
|
let resp = self.client
|
|
.post(format!("{}/generate", self.base_url))
|
|
.json(&req)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("generate request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("generate error: {text}"));
|
|
}
|
|
resp.json().await.map_err(|e| format!("generate parse error: {e}"))
|
|
}
|
|
|
|
pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResponse, String> {
|
|
let resp = self.client
|
|
.post(format!("{}/rerank", self.base_url))
|
|
.json(&req)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("rerank request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("rerank error: {text}"));
|
|
}
|
|
resp.json().await.map_err(|e| format!("rerank parse error: {e}"))
|
|
}
|
|
|
|
/// Force Ollama to unload the named model from VRAM (keep_alive=0).
|
|
/// Used for predictable profile swaps — without this, Ollama holds a
|
|
/// model for its configured TTL (default 5min) and the previous
|
|
/// profile's model can linger in VRAM next to the new one.
|
|
pub async fn unload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
|
let resp = self.client
|
|
.post(format!("{}/admin/unload", self.base_url))
|
|
.json(&serde_json::json!({ "model": model }))
|
|
.send().await
|
|
.map_err(|e| format!("unload request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("unload error: {text}"));
|
|
}
|
|
resp.json().await.map_err(|e| format!("unload parse error: {e}"))
|
|
}
|
|
|
|
/// Ask Ollama to load the named model into VRAM proactively. Makes
|
|
/// the first real request after profile activation fast (no cold-load
|
|
/// latency).
|
|
pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> {
|
|
let resp = self.client
|
|
.post(format!("{}/admin/preload", self.base_url))
|
|
.json(&serde_json::json!({ "model": model }))
|
|
.send().await
|
|
.map_err(|e| format!("preload request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("preload error: {text}"));
|
|
}
|
|
resp.json().await.map_err(|e| format!("preload parse error: {e}"))
|
|
}
|
|
|
|
/// GPU + loaded-model snapshot from the sidecar. Combines nvidia-smi
|
|
/// output (if available) with Ollama's /api/ps.
|
|
pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> {
|
|
let resp = self.client
|
|
.get(format!("{}/admin/vram", self.base_url))
|
|
.send().await
|
|
.map_err(|e| format!("vram request failed: {e}"))?;
|
|
resp.json().await.map_err(|e| format!("vram parse error: {e}"))
|
|
}
|
|
}
|