Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 54 additions & 4 deletions crates/server/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ domain model in `eidetic-core`.
| `agent_workflow_harness_tests.rs` | Provider, manifest, executor, cancellation and exact-budget lifecycle regressions against SQLite history. |
| `agent_premise_workflow.rs` | First premise graph-context workflow slice over backend graph reads, reviewable proposals, and harness history. |
| `export_service.rs` | Host-neutral PDF export behavior consumed by Tauri commands. |
| `reference_service.rs` | Canonical reference list/upload/delete and source-bound asynchronous embedding publication. |
| `embeddings.rs` | Transitional HTTP embedding adapter validating returned model, input index and finite nonzero vectors. |
| `reference_service.rs` | Canonical reference list/upload/delete, explicit index status/reindex and all-or-nothing source-bound embedding publication; reopen rebuilds disposable index state. |
| `embeddings.rs` | Independently configured real Pumas typed embeddings and authenticated OpenAI-compatible embedding adapter, binding provider/model/profile/load revision and finite nonzero vectors. |
| `pumas_inference.rs` | Fenced Pumas discovery, typed operation v1/capability reads, model metadata revision and explicit serving lifecycle RPC consumer. |
| `pumas_inference_tests.rs` | Controlled HTTP fixtures proving load/use/unload, reference upload/reopen/model-revision refusal, visible errors and retrieved prompt contributions; no real-model inference claim. |
| `pumas_runtime.rs` | Optional selected-root Pumas control-plane bootstrap and generation-fenced owned shutdown. Explicit drain retains child/output completion; cancelled construction and last-Drop observation remain gated by the lifecycle report. Borrowed services are never stopped. |
| `vector_store.rs` | Disposable exact-source index with publication tickets, representation-space filtering and stable ranking. |
| `vector_store_tests.rs` | Source custody, stale lifecycle, incompatible representation and numeric ranking regressions. |
| `reference_retrieval_tests.rs` | Actual RAG attach-boundary regression coverage for queued/reopened sessions and late query results. |
Expand Down Expand Up @@ -305,10 +308,23 @@ Saved-project listings read modification metadata from project.db, so editing an
queued retrieval. Create/load publish path, project and index under the project
guard. Source checks happen again before
ranking under project-then-index lock order. No lock crosses model I/O.
Retrieval verifies the captured index scope under that same guard before any
compatibility/status mutation; a late query cannot mark a newer reindexed or
same-path reopened document stale. Regression checks assert the complete fresh
status remains unchanged and its current query still retrieves the source.
- Only nonempty finite nonzero vectors with matching configured endpoint/model
and dimension are ranked. Returned HTTP model identity must match the request.
This does not attest immutable model weights; Pumas revision-aware embeddings,
live runtime qualification and saved-reference re-indexing remain M3 work.
Pumas embeddings now bind producer instance/service generation, provider,
selected profile/model, monotonic serving cursor, optional load timestamp and actual library metadata, including
upstream revision when present. Dimension mismatch excludes candidates.
The pinned producer omits load timestamps; its service-scoped cursor fences
same-model reloads with unchanged metadata. Any serving mutation, including an
unrelated chat load, conservatively requires reindexing. Before/after inference
observations reject an operation that crosses a serving-state change.
This is session/provenance identity, not an immutable weight attestation;
out-of-band file mutation without metadata/reload is outside the producer's
current contract. Saved sources rebuild on reopen; failed/stale/indexing states
are projected by `reference_index_status`. See `docs/pumas-local-inference.md`.
- Generation and preview accept an optional explicit fictional `story_time_ms`.
Snapshot fields resolve independently at or before it. Conflicting values at
the latest effective timestamp stop context construction. A cleared value is
Expand Down Expand Up @@ -699,6 +715,40 @@ recap titles actually supplied before provider I/O. Existing graph edges and
proposal bindings support exact impact, stale/ABA refusal, historical/current or
owned-deletion evidence, and explicit acceptance with refreshed consumed lineage.

Local inference uses the existing configuration facade plus `ai_config_get`,
`pumas_catalog`, `pumas_load_model`, `pumas_unload_model`,
`reference_index_status` and `reference_reindex` desktop commands. Chat Pumas
selection requires explicit installed model/profile; embedding selection has
separate provider, endpoint, model/profile and credentials. Typed stream terminal
failures never publish partial screenplay. Reference retrieval emits source names
or a visible error before the actual generation context event. Disabled or failed
retrieval permits ordinary generation with the error displayed; it does not claim
references contributed. Configuration is application-session state, as before;
references are canonical saved SQLite project content.

The Linux cancelled-startup fixture observes actual `/proc` child disappearance
after the owned wait receipt before deleting its signal files or ending the test
runtime; a recorded shutdown RPC alone is not process-cessation evidence.

Explicit Pumas shutdown retains the child and stdout-drain completion together,
including I/O errors and panics. Cancelling a shutdown waiter leaves that output
handle available to the next waiter. A hung borrowed bootstrap is terminated
and reaped as our helper only, without sending shutdown/unload to the borrowed
service. Full cancelled-construction and last-Drop completion ownership still
requires a retained composition owner; see the
[lifecycle repair and remaining decision](../../../docs/reports/pr29-lifecycle-ownership.md).

Explicit selected-root bootstrap failures preserve Pumas as the selected backend
and expose the actual startup error in AI status. They never silently revert to
a direct llama.cpp endpoint; an operator can supply a valid explicit Pumas URL
to retry connection through the existing configuration control.

Unload decodes Pumas's actual `UnserveModelResponse`: an `error` is failure even
when `success` is true; `unloaded: false` without an error is an already-unserved
outcome rather than a successful unload receipt. Missing required receipt fields
are rejected. HTTP fixtures retain the loaded model after ONNX/Ollama-shaped
unload failures and verify the actual provider diagnostic reaches the caller.

## Canonical arc assignment and sibling reorder

`timeline_arc_membership` validates assigned IDs and owned field reads in the
Expand Down
11 changes: 10 additions & 1 deletion crates/server/src/ai_backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ This directory implements server-side adapters that translate core AI requests i
| File/Folder | Description |
|-------------|-------------|
| `mod.rs` | Shared backend trait and adapter selection. |
| `llamacpp.rs` | Local Pumas llama.cpp OpenAI-compatible adapter. |
| `llamacpp.rs` | Existing direct local llama.cpp OpenAI-compatible adapter. |
| `pumas.rs` | Managed Pumas typed chat operation v1 with capability checks, fenced transport and JSON-output validation. |
| `openrouter.rs` | OpenRouter HTTP adapter. |
| `sse.rs` | Shared stateful SSE framing, completion validation and transport error propagation. |
| `sse_tests.rs` | Buffer boundaries, fragmented framing, malformed input and bounded provider diagnostics. |
Expand Down Expand Up @@ -66,3 +67,11 @@ use crate::ai_backends::AiBackend;
## Structured Producer Contract
- Backend adapters must preserve the field semantics defined by core AI request/response types.
- Changes to response interpretation require coordinated route and frontend progress-handling updates.

Pumas typed SSE uses `started`, `delta`, `completed` and `failed` JSON events,
checks contract version and request identity, and requires a valid terminal event
plus clean HTTP EOF. The existing OpenAI-compatible providers continue to require
`[DONE]`. Typed Pumas v1 has no JSON-mode option: structured calls explicitly
request JSON in their prompt and validate JSON before the established parsers.
This follows the producer contract at `ad31e391dcd94c204b3fcd748cc98d27b3281e65`;
no direct ONNX session or fake backend is selected in production.
6 changes: 6 additions & 0 deletions crates/server/src/ai_backends/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub(crate) mod llamacpp;
pub(crate) mod openrouter;
mod pumas;
mod sse;

use serde::Serialize;
Expand All @@ -13,11 +14,13 @@ use eidetic_core::error::Error;
pub(crate) enum Backend {
LlamaCpp(llamacpp::LlamaCppBackend),
OpenRouter(openrouter::OpenRouterBackend),
Pumas(pumas::PumasBackend),
}

impl Backend {
pub fn from_config(config: &AiConfig) -> Self {
match config.backend_type {
BackendType::Pumas => Backend::Pumas(pumas::PumasBackend::new(config)),
BackendType::LlamaCpp => Backend::LlamaCpp(llamacpp::LlamaCppBackend::new(config)),
BackendType::OpenRouter => {
Backend::OpenRouter(openrouter::OpenRouterBackend::new(config))
Expand All @@ -33,6 +36,7 @@ impl Backend {
match self {
Backend::LlamaCpp(b) => b.generate(prompt, config).await,
Backend::OpenRouter(b) => b.generate(prompt, config).await,
Backend::Pumas(b) => b.generate(prompt, config).await,
}
}

Expand Down Expand Up @@ -61,13 +65,15 @@ impl Backend {
match self {
Backend::LlamaCpp(b) => b.generate_json(prompt, config).await,
Backend::OpenRouter(b) => b.generate_json(prompt, config).await,
Backend::Pumas(b) => b.generate_json(prompt, config).await,
}
}

pub async fn health_check(&self) -> Result<BackendStatus, Error> {
match self {
Backend::LlamaCpp(b) => b.health_check().await,
Backend::OpenRouter(b) => b.health_check().await,
Backend::Pumas(b) => b.health_check().await,
}
}
}
Expand Down
90 changes: 90 additions & 0 deletions crates/server/src/ai_backends/pumas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use eidetic_core::{Error, ai::backend::GenerateStream};
use serde_json::json;

use super::BackendStatus;
use crate::{
prompt_format::ChatPrompt,
pumas_inference::{PumasClient, PumasOperation},
state::{AiConfig, BackendType},
};

pub(crate) struct PumasBackend {
config: Box<AiConfig>,
}
impl PumasBackend {
pub(crate) fn new(config: &AiConfig) -> Self {
Self {
config: Box::new(config.clone()),
}
}
pub(crate) async fn generate(
&self,
prompt: &ChatPrompt,
config: &AiConfig,
) -> Result<GenerateStream, Error> {
let client = PumasClient::connect(&config.base_url)
.await
.map_err(Error::AiBackend)?;
let (id, response) = client.operation(PumasOperation {
model: &config.model,
profile: &config.pumas_profile,
capability: "chat_generation",
input: json!({"kind":"messages","messages":[{"role":"system","content":prompt.system},{"role":"user","content":prompt.user}]}),
output: "text",
options: json!({"kind":"text_generation","max_tokens":config.max_tokens,"temperature":config.temperature}),
stream: true,
}).await.map_err(Error::AiBackend)?;
Ok(super::sse::pumas_tokens(
response,
id,
config.model.clone(),
config.pumas_profile.clone(),
))
}
pub(crate) async fn generate_json(
&self,
prompt: &ChatPrompt,
config: &AiConfig,
) -> Result<String, Error> {
// Typed v1 has no JSON-mode option. Require JSON in the prompt and validate
// it here before the existing structured-output parsers see it.
use futures::StreamExt;
let prompt = ChatPrompt {
system: format!(
"{}\nReturn only valid JSON without Markdown fences.",
prompt.system
),
user: prompt.user.clone(),
};
let mut stream = self.generate(&prompt, config).await?;
let mut full = String::new();
while let Some(token) = stream.next().await {
full.push_str(&token?);
}
serde_json::from_str::<serde_json::Value>(&full)
.map_err(|e| Error::AiBackend(format!("Pumas returned invalid JSON: {e}")))?;
Ok(full)
}
pub(crate) async fn health_check(&self) -> Result<BackendStatus, Error> {
let client = PumasClient::connect(&self.config.base_url)
.await
.map_err(Error::AiBackend)?;
client
.selected(
&self.config.model,
&self.config.pumas_profile,
"chat_generation",
)
.await
.map_err(Error::AiBackend)?;
Ok(BackendStatus {
connected: true,
model: self.config.model.clone(),
backend_type: BackendType::Pumas,
message: format!(
"Pumas model {} loaded on {}",
self.config.model, self.config.pumas_profile
),
})
}
}
58 changes: 57 additions & 1 deletion crates/server/src/ai_backends/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct Reader {
data: Vec<u8>,
completed: bool,
provider: &'static str,
pumas_request: Option<(String, String, String)>,
started: bool,
}

impl Reader {
Expand All @@ -43,12 +45,47 @@ impl Reader {
if self.completed {
return Err(self.error("received data after completion"));
}
if text.trim() == "[DONE]" {
if self.pumas_request.is_none() && text.trim() == "[DONE]" {
self.completed = true;
return Ok(None);
}
let value: serde_json::Value =
serde_json::from_str(text).map_err(|error| self.error(error))?;
if let Some((id, model, profile)) = &self.pumas_request {
if value["request_id"] != *id {
return Err(self.error("Pumas stream correlation mismatch"));
}
match value["kind"].as_str() {
Some("started")
if !self.started
&& value["contract_version"] == 1
&& value["capability"] == "chat_generation"
&& value["model"] == *model
&& value["profile"] == *profile =>
{
self.started = true;
return Ok(None);
}
Some("delta") if self.started => {
return value["text"]
.as_str()
.map(|text| Some(text.to_owned()))
.ok_or_else(|| self.error("invalid Pumas text delta"));
}
Some("completed")
if self.started
&& matches!(
value["finish_reason"].as_str(),
Some("stop" | "length" | "content_filter")
) =>
{
self.completed = true;
return Ok(None);
}
Some("failed") => return Err(self.error(&value["error"])),
_ => return Err(self.error("invalid Pumas stream sequence")),
}
}
if let Some(error) = value.get("error") {
return Err(self.error(error));
}
Expand Down Expand Up @@ -126,6 +163,23 @@ impl std::fmt::Write for ErrorDetail {
}

pub(super) fn tokens(response: reqwest::Response, provider: &'static str) -> GenerateStream {
stream_tokens(response, provider, None)
}

pub(super) fn pumas_tokens(
response: reqwest::Response,
request_id: String,
model: String,
profile: String,
) -> GenerateStream {
stream_tokens(response, "Pumas", Some((request_id, model, profile)))
}

fn stream_tokens(
response: reqwest::Response,
provider: &'static str,
pumas_request: Option<(String, String, String)>,
) -> GenerateStream {
let reader = Reader {
body: response
.bytes_stream()
Expand All @@ -137,6 +191,8 @@ pub(super) fn tokens(response: reqwest::Response, provider: &'static str) -> Gen
data: Vec::new(),
completed: false,
provider,
pumas_request,
started: false,
};
Box::pin(stream::try_unfold(reader, |mut reader| async move {
loop {
Expand Down
Loading
Loading