Specification: Prism (prism-local)¶
This file records what must stay true (invariants), what happens when things go wrong (failure modes), behavior that is planned
but not built, and the contract of the development tooling. It does not repeat the wire formats: docs/api.md (HTTP),
docs/cli.md (commands) and docs/getting-started.md (environment variables) are normative for those. Change them in the
same commit as the behavior.
1. Components¶
docs/architecture.md describes the modules and the request flow. In short: cli.py turns subcommands into calls; catalog.py
resolves model names; engine.py (OnnxGenAiEngine) loads and streams ONNX models; ollama_bridge.py talks to Ollama;
server.py is the OpenAI-compatible server and owns the engine lock (ActiveEngineManager); telemetry.py reads the GPU;
mcp.py and connectors.py serve and configure MCP and editor clients.
2. Invariants¶
Tests and reviews cite these by number. Changing one needs operator approval.
| # | Invariant |
|---|---|
| I1 | Device honesty. The execution provider actually used is reported (prism run stderr, GET /health active_device, telemetry.device, prism benchmark). A fallback is never silent: --device auto that falls back to CPU warns with the underlying error; --device cuda fails with an error naming the cause. |
| I2 | One resident ONNX model, one lock. Loading and generation are serialized in ActiveEngineManager.use_engine; asking for another ONNX model unloads the current one. Ollama requests are not serialized by Prism. |
| I3 | Errors before headers. Model loading and the first Ollama chunk are resolved before any response header is sent, so failures are JSON errors, not dropped connections. |
| I4 | No runtime dependencies. The package imports only the standard library at import time; optional engines are imported lazily and their absence is handled. |
| I5 | Safe defaults. Loopback bind, no CORS headers, non-loopback Host rejected on a loopback bind, 10 MB body cap, constant-time bearer comparison, and a warning for a non-loopback bind without an API key. |
| I6 | MCP never touches user files. The MCP tools generate, list, report status and benchmark; they do not read or write the user's files. |
| I7 | Templates are sandboxed. A model's chat_template is rendered only inside ImmutableSandboxedEnvironment; a template that fails or is blocked is logged and replaced by the built-in format. |
| I8 | Tests are hermetic. They use fakes (FakeEngine, FakeOg), temp directories and create_server(port=0); only tests/test_prism_gpu_integration.py uses hardware, and it skips itself. |
| I9 | ACP agent never touches user files or runs commands. A prism acp model tool call that wants to read or write a file, or run a command, is mediated by the ACP client's fs/* or terminal/* capabilities only; the agent itself never opens a file, never spawns a process, and never reads a path directly (matching intent.md §3.7 and the spirit of I6 for MCP). |
3. Failure modes (current behavior)¶
| Situation | Behavior | Where specified |
|---|---|---|
ONNX model cannot be loaded (e.g. --device cuda with a broken CUDA setup) |
500 model_load_failed |
docs/api.md Errors |
Model stays busy longer than --queue-timeout (default 300 s) |
503 server_busy, Retry-After: 30 |
docs/api.md Concurrency |
| Ollama unreachable or failing | 502 backend_unavailable |
docs/api.md Errors |
Prompt fills the context window (ONNX, known context_length) |
400 context_length_exceeded |
docs/api.md Errors |
| Name matches several models | 400 ambiguous_model, message lists them |
docs/api.md Errors |
Non-zero frequency_penalty / presence_penalty on an ONNX model |
400 unsupported_parameter |
CHANGELOG.md |
| An ONNX generation ends in a repeating token cycle | Stopped after a warning, reported as finish_reason: length; PRISM_LOOP_GUARD=off disables |
CHANGELOG.md |
| Long prompt on ONNX | Prefill in chunks of PRISM_PREFILL_CHUNK (default 1024) to bound GPU memory |
docs/devices.md |
| Client disconnects while waiting for the engine (queued behind another request) | No HTTP response is attempted (the peer is gone); the request is removed from the queue immediately and the engine lock is left for the next caller; logged at debug | docs/api.md Concurrency |
prism mcp reaches $PRISM_MCP_AUTO_STOP_SEC seconds without a tool call |
Process exits with status 0 and a one-line stderr notice; intended for test harnesses that start MCP and want it to disappear after the run | docs/getting-started.md Environment |
4. Planned behavior¶
Phase 2: Reasoning content separation (plan.md Phase 2)¶
- P7 Reasoning content separation:
Models emitting
<think>...</think>blocks (such as Qwen 2.5/3 with thinking, DeepSeek-R1) have their thoughts separated from final answer text for/v1/chat/completions. - Non-streaming:
choices[0].message.reasoning_contentcarries the thinking text (stripped of<think>and</think>tags).choices[0].message.contentcarries the remaining answer text. If<think>is unclosed at the end of generation (e.g. truncated bymax_tokens), the thinking text goes toreasoning_contentandcontentis"". If no<think>block is generated,reasoning_contentis omitted. - Streaming: Tokens generated inside
<think>...</think>are emitted asdelta: {"reasoning_content": piece}. Neither opening nor closing tags are emitted in the stream. Subsequent tokens are emitted asdelta: {"content": piece}. A lookahead buffer (up to 8 characters) cleanly handles tags split across chunk boundaries without leaking tag fragments. - Ollama: If Ollama emits
message.thinkingor<think>tags, it is handled identically, deliveringreasoning_content. - Legacy text completions (
/v1/completions): Unaffected; returns raw text. - Tool calling: If a reasoning model emits
<think>...</think>before a tool call,reasoning_contentis extracted and provided alongsidetool_calls.
Phase 4: Operator-driven model unload (plan.md Phase 4)¶
- P8 Operator unload (
POST /v1/unload): drops the ONNX model currently held byActiveEngineManagerand frees its VRAM/RAM, so an external tool (e.g. a benchmark script) can swap to a different large model without restartingprism serve. - Auth: requires the API key (default behavior of
_dispatchfor any path not listed aspublic); without--api-key, every caller is on loopback and the endpoint is open there. - Request body: optional. With
Content-Length: 0(or absent) the body is treated as{}; any JSON object is accepted and ignored (reserved for future options such as a timeout). A non-empty body that is not a JSON object returns400. - Response:
200 {"unloaded": bool, "model": string|null}.unloadedistrueif a model was resident and is now released,falseotherwise (idempotent: repeated calls succeed and reportfalseonce nothing is loaded).modelis the id of the released model, ornullwhen none was held. - Concurrency:
manager.unload()acquires the engine lock, so if a generation is in flight the unload waits for it to finish. The HTTP call is therefore synchronous and may take seconds; a request that wants a fast yes/no should checkGET /health(active_model) before calling. - Scope: only ONNX models go through
ActiveEngineManager; Ollama models are not loaded into the server process and are unaffected. Loading another model afterunload()re-runs the resource budget check (Phase 1, P1). - Failure modes:
400 invalid_request_errorfor a non-empty, non-object body or for an invalidContent-Lengthheader;401 invalid_api_keywhen the bearer token does not match--api-key;404 not_foundfor any other path. Non-POSTmethods inherit the stdlib behavior:GET /v1/unloadreturns404(the path is unknown todo_GET's route map), other methods return501 UnsupportedfromBaseHTTPRequestHandler. No new state on the server, no new environment variable.
Phase 5: Cancel queued request when client disconnects (plan.md Phase 5)¶
- P9 Queue cancellation on client disconnect: A request that is queued behind another in
ActiveEngineManager.use_engineno longer blocks until--queue-timeoutwhen its HTTP connection has already been closed by the peer. - Trigger: a chat/completions POST whose thread is parked in
ActiveEngineManager.use_enginewaiting forself.lock. Today, the wait is a single blockingacquire(timeout=queue_timeout), so the queue slot (waiting_count) is held for up to--queue-timeoutseconds (default 300) after disconnect; with--max-queue 1this starves the next caller and reserves a slot for a peer that will never read the response. - Behavior:
ActiveEngineManager.use_engine(resolved, is_alive=None)gains an optionalis_alive: Callable[[], bool]. WhenNonethe manager behaves exactly as today. When supplied, the manager poll-iterates with a 50 ms tick: each iteration checksis_alive()first (short-circuit: if the peer is gone, raise), then triesself.lock.acquire(timeout=tick). If the lock is acquired but the peer has gone in the meantime (between theis_alivecheck and the acquire returning), the manager releases the lock and raises — the slot is freed before any model work. The exception isprism.server.ClientDisconnectedError; it is caught byOpenAIApiHandler._dispatchnext toBrokenPipeErrorandConnectionResetErrorand logged at debug; no HTTP response is written because the peer is gone. - Detection (
is_connection_alive): a module-level helperis_connection_alive(conn) -> boolthat temporarily sets the socket non-blocking and callsconn.recv(1, socket.MSG_PEEK). Empty bytes mean the peer performed an orderly shutdown (FIN observed).BlockingIOErrormeans alive with no data pending. Any otherOSError(ConnectionResetError,BrokenPipeError, etc.) means dead. The socket's previous blocking state is restored before returning. The handler passes a closurelambda: is_connection_alive(self.connection)intouse_engine. - Scope: the
is_aliveclosure is wired in_generate, which is the shared body of both/v1/chat/completions(_handle_chat_completions) and/v1/completions(_handle_completions)./v1/embeddingsand/v1/unloaddo not calluse_engine. Ollama backends do not enterActiveEngineManagerand are unaffected. Mid-generation disconnects remain covered by the SSE write catchingBrokenPipeError/ConnectionResetErrorin_generate(seeTestClientDisconnect::test_generation_stops_when_client_goes_away) — this change does not touch that path. queue_timeout == 0: the poll loop normalisesself.queue_timeout <= 0toNone("wait forever"), matching the env-var path ofdefault_queue_timeout()(which maps0toNone) and the pre-P9 behaviour of theis_alive=Nonebranch (acquire(timeout=-1)). A CLI--queue-timeout 0therefore still means "wait forever"; only anis_alivepeer that is gone short-circuits the wait.
Phase 6: MCP auto-stop for test harnesses (plan.md Phase 6)¶
- P10 MCP auto-stop (
$PRISM_MCP_AUTO_STOP_SEC, default0= disabled): the stdio MCP server exits with status 0 after the configured number of seconds with notools/call. The timer is reset on every successful tool call (sliding window). Intended for test harnesses that startprism mcpto satisfy editor integrations but never drive it; without this knob the process lingers and holds its Python/CUDA startup overhead (~1.6 GB VRAM passive) for the rest of the session. - Semantics: a positive float sets the idle window.
0or unset = no auto-stop (current behaviour). Negative = rejected at startup (ValueError). The exit goes throughos._exit(0)so no further requests on stdin are processed; a one-line stderr notice names the timeout and the env var so the cause is obvious in a test log. - Independence: this is a separate timer from the existing
_schedule_idle_unload()(which only fires after a model has been loaded into the MCP process — i.e. when MCP is serving tool calls itself, not going throughprism serve). The auto-stop timer fires regardless of whether a model was ever loaded, and covers the case the existing timer misses (MCP idle since startup). - Scope:
prism.cli mcponly; no effect onprism serve,prism chat,prism benchmarkor any other subcommand. No HTTP endpoint. No CLI flag (env var only) so the subprocess invocation stays minimal in test harnesses. - Failure modes: there is no recoverable error path — when the timer fires, the process exits. A request already in flight when the timer fires still gets its response (the timer uses a daemon thread and does not interrupt
handle_tool_call). Atools/callafter the timer has fired is impossible because stdin is closed byos._exit. - Configuration:
$PRISM_MCP_AUTO_STOP_SEC(positive float, seconds). No CLI flag, no entry indocs/cli.mdformcp; documented indocs/getting-started.mdEnvironment. - Failure modes: no new HTTP status. The peer is gone, so there is no response to write. Disconnect during the lock-acquire poll is silent (debug log). Disconnect detected after acquire but before generation starts releases the lock and is silent. Disconnect detected during generation still surfaces as a
BrokenPipeErroron the nextwfile.writeand is caught as today. The existing503 server_busyrow is unchanged: a peer that is still connected but waited too long still gets the same503withRetry-After: 30. - Configuration: no new flag, no new environment variable.
--queue-timeoutkeeps its meaning for callers that are connected;--max-queuekeeps its meaning for callers that are connected.
Phase 7: No silent tool-drop on chat-template render failure (plan.md Phase 7)¶
- P11 No silent tool-drop on render failure: when a chat completion request carries
toolsand the model's Jinja chat template raises duringrender_chat_template(text, messages, tools=tools)(e.g. the template references a field the caller's tool definitions do not provide), Prism no longer falls back to the built-informat_prompt, which has notoolsargument and would silently hand the model a prompt without the tool definitions. The failure now surfaces as400 invalid_request_errorwitherror.code = "template_render_failed"and amessagenaming the model id and the underlying jinja/template exception. - Why: a silent fallback that drops a request's
toolsargument is a silent change to what the model sees (intent.md§3.3 calls a silent fallback a bug; the same principle applies here — the caller asked for tools and got none without warning). - Scope: ONNX backend only; Ollama handles tools itself and does not call
render_promptfor tool placement. - No-tools case: unchanged. When
tools is None, the existing "log and fall back to the built-in format" path stays (invariant I7 still applies — a template that fails on the messages alone is harmless, the built-in format renders the same conversation). - Where the check fires:
_dispatchalready turnsApiErrorinto the JSON error body, so the natural place to convert the new exception is the single call torender_prompt(resolved, messages, tools)in_generate(prism/server.py). Other callers ofrender_prompt(cli.py,chat.py,mcp.py,benchmark.py) do not passtools, so the no-tools path is unchanged for them. - Failure modes:
400 template_render_failedwitherror.messagenaming the model id and the underlying jinja/template exception text. No5xx; the failure is a client-side issue (the template does not accept the provided tool definitions), not a server fault. The engine lock is released byActiveEngineManager.use_engineon the way out, so no slot is held. - Docs:
docs/api.mdErrors table gets a row fortemplate_render_failed.CHANGELOG.md[Unreleased]gets a### Changedentry.
Phase 8: Drain endpoint and structured holder detail (plan.md Phase 8)¶
- P12 Drain the server and structured holder info:
POST /v1/drainasks the runningprism serveto finish its current request (the engine lock waits for the in-flight generation), unload the ONNX model, and exit with status 0. The HTTP response is sent first; the process exits throughos._exit(0)on a daemon thread ~250 ms later so the response can be flushed. The response body is{"drained": bool, "model": str|null, "exit_in_ms": int}. Idempotent (no model →drained: false, model: null, exit_in_ms: 250).- Why: a benchmarking or evaluation tool that needs a different model on the same VRAM cannot ask a
running
prism serveto step aside today.POST /v1/unloadreleases the model but keeps the server alive, so the bench would still hit the same process on the next request.POST /v1/drainlets the orchestrator replace the server (e.g. start its ownprism servewith a large model, run the eval, restart the original if needed). Stdlib-only (os._exit,threading.Timer). - Auth: same as
/v1/unload(default: loopback open;--api-keymakes itBearer-required). - Request body: optional.
Content-Length: 0(or absent) →{}. Any JSON object is accepted and ignored today (reserved for a futuretimeout_ms). A non-empty body that is not a JSON object →400. - Failure modes:
400 invalid_request_errorfor a non-empty non-object body or invalidContent-Length;401 invalid_api_keywhen the bearer does not match--api-key;404 not_foundfor any other path. Non-POSTmethods inherit the stdlib behaviour (GET /v1/drain→404, other →501). No new state on the server, no new environment variable, no new CLI flag. - Interaction with the engine queue: a drain waits for the current
use_enginecontext to exit (manager.unload()already holds the engine lock; the next request that arrives in the 250 ms window beforeos._exit(0)enters_generate, seesmanager.engine is None, and starts a fresh model load that is interrupted mid-flight whenos._exit(0)fires. The dropped request sees a TCP reset or a partial response. The 250 ms window is sized for a TCP close handshake, not for new requests — clients must treat the drain reply as the last response they will see from this server). - Docs:
docs/api.mdadds the route to the routing table and the endpoint to the Errors / Endpoints table.docs/devices.md"Parallel use" gets a short paragraph on the orchestrator pattern.CHANGELOG.md[Unreleased]gets a### Addedentry.
- Why: a benchmarking or evaluation tool that needs a different model on the same VRAM cannot ask a
running
error.holderon503 insufficient_resources: when the model load lock is held by another process, the503JSON now carries a structurederror.holder = {"pid": int, "model": str}in addition to the existing prose inerror.message(which stays — logs and humans still parse it). The shape matches the on-disk lock metadata (prism/machine_lock.py::_read_holder_info). When no holder is recorded,error.holderis omitted (notnull), so the orchestrator'sif "holder" in error:is the right check. Same auth, sameRetry-After: 30, samecode: "insufficient_resources". No new files; the change is inprism/server.py(ApiError.__init__acceptsholder: Optional[Dict[str, Any]],_send_api_errorincludes it when set; theInsufficientResourcesErrorhandler reads_read_holder_info()and passes the dict).docs/api.mdErrors table row for503 insufficient_resourcesdocuments the new field.CHANGELOG.md[Unreleased]gets a### Changedentry.
Phase 9: Graceful shutdown during in-flight SSE responses (plan.md Phase 9)¶
- P13 Graceful shutdown while a streamed response is in flight:
- When
prism serveis interrupted (Ctrl+C / SIGTERM with default handler;POST /v1/drainis its own path, covered by P12), the server printsShutting down server..., waits for the in-flight generation under the engine lock (manager.unload()), and exits0oncewith server:closes the listening socket. OpenAIApiHandler._begin_sse,_sse,_sse_error, the streaming loops in_generate/_generate_ollama, and the JSON helper_send_json(plus the CORS preflightdo_OPTIONS) must not raise an unhandled exception when the HTTP connection is closed by shutdown underneath them:send_response,send_header,end_headersandwfile.writecalls sit insidetry/except (OSError, ValueError)(the implementation is a single helper,OpenAIApiHandler._safe_write). Hits are logged at debug, the response is dropped, no synthetic5xxis written (headers may already be gone or the next write would fail anyway).OSErrorcoversBrokenPipeError,ConnectionResetError, andEBADF;ValueErrorcovers theBufferedWriter"I/O operation on closed file." case that is not anOSErrorsubclass.- The streaming loops' existing
(BrokenPipeError, ConnectionResetError)clauses widen to the same(OSError, ValueError)tuple._ssere-raisesConnectionResetErrorwhen its write was swallowed so the streaming loops' existing catch aborts generation as before — the loop does not run the engine to completion with no reader. _dispatch's(BrokenPipeError, ConnectionResetError, ClientDisconnectedError)catch is deliberately not widened to four classes: a handler may legitimately raiseOSError/ValueErrorfrom its own logic (file paths, int parsing, …) and those must reach theexcept Exceptionbranch, not be silently treated as "client disconnected". I/O failures the response writers raise are pre-empted at the point of the failing write.
- Failure modes:
- Client disconnects between dispatch and
_begin_sse→ no response written, debug log only. (Invariant I3 still holds: model work and the first Ollama chunk finish before headers go out.) - Streaming response interrupted by shutdown → SSE write fails inside
_safe_write, debug-logged;_ssere-raisesConnectionResetError; the streaming loop's outerexcept (OSError, ValueError)debug-logs, closes the engine generator infinally, and the handler thread exits cleanly. The process exits0oncemanager.unload()returns and the lock is released. - Non-streaming response raced by shutdown → same swallow at the
_safe_writesite, debug log only.
- Client disconnects between dispatch and
- Why: today only
BrokenPipeError/ConnectionResetErrorare caught inside the streaming loops;_begin_sse,_send_json, and_sse_errorare unguarded. Writes that hit a closed-by-shutdown file raiseOSError([Errno 9] Bad file descriptor)orValueError("I/O operation on closed file."), the traceback prints at the same time asShutting down server..., and the operator reads it as a crash even thoughdaemon_threads = Truekeeps the process alive. - Stdlib-only. No new env var, no new CLI flag, no new endpoint.
- No invariant changes (I1–I8 still hold).
- Docs:
docs/api.mdConcurrency paragraph gains one sentence naming the shutdown behaviour and the0exit code.CHANGELOG.md[Unreleased]gets a### Fixedentry.
Phase 10: Honor tool_choice on the agent path (plan.md Phase 10)¶
- P14
tool_choiceis honored;supports_toolsis a structural check: - Today,
OpenAIApiHandler._tools_paramonly special-casestool_choice == "none"(prism/server.py); every other value, including"required", the structured{"type": "function", "function": {"name": ...}}, and the OpenAI default"auto", is silently treated as"auto"— the full tools list goes to the engine even when the caller explicitly named one tool. That makestool_choice: {"type": "function", "function": {"name": "get_weather"}}a silent fallback on every model that would have picked a different tool, exactly the failure modeintent.md §3.3calls "a silent fallback is a bug" on the agent path. - Behaviors:
tool_choice == "none"→ tools list returnsNone(unchanged).tool_choice == "required"→ tools list returned in full; the engine's own tool-aware template is the only way to "force" a call on the open-source models Prism targets, and that is what today's rendering already does. The docstring makes this contract explicit instead of hiding the auto-fallback behind a verbose internal name.tool_choice == "auto"(or absent) → tools returned in full (unchanged).tool_choice == {"type": "function", "function": {"name": "X"}}→_tools_paramnarrows the list to the tool whose name matchesX. The model only sees that one tool definition, which is what OpenAI promises.- Any other shape →
400 invalid_request_errorwith a message naming the expected shapes ("none" | "auto" | "required" | {"type": "function", ...}).
supports_toolsis no longer are.search(r"\btools\b", text)text scan (prism/templates.py). It is replaced with a structural check that only matches thetoolssymbol when it appears inside a Jinja expression or block:\{\{[^}]*\btools\b[^}]*\}\}|\{\%[^%]*\btools\b[^%]*%\}. The first branch is the{{ … tools … }}expression form; the second covers every Jinja block that usestools({% if tools %},{% for t in tools %},{% for tools in x %},{% set t = tools %},{% if not tools %}— the only requirement is that the block contains the symbol). A\btools\bword boundary excludestool_registry,tools_dict, etc. Jinja comment blocks ({# … #}) are excluded because their delimiters are{# … #}, not{% … %}. Templates whose chat_template file is missing the keyword in any Jinja block returnFalse, so the existing400 tools_not_supportedpath is reached on the same models as today — and True results now correspond to genuine template support, not prose.- No invariant changes (I1–I8 still hold; I1 strengthens: the new heuristic is a structural match, not a textual one, so a model whose template documents "tools" without rendering them does not falsely claim support).
- Stdlib-only. No new env var, no new CLI flag, no new endpoint.
- No engine changes; the contract is honoured in the HTTP layer.
- Docs:
docs/api.md"Tool calling" gains a paragraph listing exactly whichtool_choicevalues Prism honours and which it 400s.CHANGELOG.md[Unreleased]gets a### Changedentry.
Phase 11: Central env-var schema, stdlib-only (plan.md Phase 11)¶
- P15 Centralised, validated, type-safe env config (no runtime deps):
- Today, 22
PRISM_*env vars are read withos.environ.get("X", default)scattered acrossprism/cli.py,prism/resources.py,prism/templates.py,prism/mcp.py, andprism/server.py(31 callsites in total). Defaults are defined alongside the readers, type coercion is hand-rolled (float(...),raw.strip().lower() not in ("off","0","false","no"),or "auto"), and a typo'd var (PRISM_DEVIC=...) is caught only at first use, silently. - Add
prism/env_config.py— stdlib only (dataclasses,os,pathlib,logging,typing,re;configparseroptional for the.envreader if a fragment form is later chosen). Single frozen dataclassEnvConfigdeclaring everyPRISM_*field with the right type, default, and a__post_init__validator (e.g.PRISM_PREFILL_CHUNK >= 0,PRISM_DEVICE in {"auto","cpu","cuda"},PRISM_TEMPLATEinTEMPLATE_MODES). EnvConfig.from_env(environ: Optional[Mapping[str, str]] = None) -> EnvConfigbuilds an instance fromos.environ(or the supplied mapping for tests).EnvConfig.from_env_file(path)parses a hand-rolledKEY=VALUEreader (whitespace,# comment, blank lines, optional double-quoted value) and merges it underenvironso the process env still overrides on conflicts.- A module-level
load_config() -> EnvConfigruns at CLI/server start. Fails fast: a malformed value (e.g.PRISM_PREFILL_CHUNK=abc) raises oneValueErrorlisting every bad field, not a deferredTypeErrorat first use. .envfile loading is opt-in only. No auto-discovery in CWD; the only way to load one isPRISM_ENV_FILE=/path/to/.envin the process env (security: never load a file the operator did not name — prevents a third-partycdfrom silently swapping API keys).- Unknown vars in the process env that begin with
PRISM_but are NOT in the dataclass are logged at WARN at startup (silent-typo guard, not an error; keeps backward compatibility withPRISM_*vars a shell export may have brought along). - This phase migrates
prism/cli.pyonly (the largest user, 5 callsites). The other four modules keep their existingos.environ.getreads until a follow-up phase migrates them one at a time. - No invariant changes (I1–I8 still hold; I4 strengthens: the schema proves "no runtime dependencies" by listing exactly what is read and how it's coerced, with every reader in one file).
- Stdlib-only. No
kev, nopydantic-settings, nopython-dotenv. - Docs:
docs/getting-started.mdEnvironment section gains a one-row-per-var table (var, type, default, valid range) and a short "Loading from a file" subsection that shows thePRISM_ENV_FILE=/path/to/.envopt-in flow with a 5-line.envexample.CHANGELOG.md [Unreleased]### Added(the loader) plus### Changed(theprism/cli.pymigration).
Phase 12: ACP agent, first milestone — handshake and a plain streamed prompt turn (plan.md Phase 12)¶
- P16
prism acp: a new stdio JSON-RPC 2.0 subcommand,prism/acp.py, structurally a sibling ofprism/mcp.py(samefor line in sys.stdinshape) but speaking the Agent Client Protocol instead of MCP. This phase covers only a plain, tool-free chat session; file/terminal mediation is Phase 13/14 (not yet planned — see "Not yet planned" below andintent.md§4.5). - Concurrency, the one real departure from
mcp.py: MCP's loop is fully synchronous — one request, one blocking handler, one response. ACP cannot be: asession/promptmay stream for tens of seconds, andsession/cancelmust be able to interrupt it while the main loop is still free to read other stdin lines. Sorun_acp_server()'s stdin loop dispatchessession/promptonto its own daemon thread (one per in-flight prompt) and keeps reading; all other methods (initialize,session/new,session/cancel) are handled inline on the main thread since they never block. A singlethreading.Lock(_stdout_lock) guards everysys.stdout.writeso a streamedsession/updatenotification from the worker thread can never interleave with another response mid-line. initialize(client→agent, request): params{"protocolVersion": int, "clientCapabilities": {...}}. Response:{"protocolVersion": <same or the highest Prism supports, whichever is lower>, "agentCapabilities": {"loadSession": false, "promptCapabilities": {"image": false, "audio": false, "embeddedContext": false}}, "authMethods": []}.loadSession: falseand no auth methods are permanent for this milestone (intent.md non-goal §4.5: no session persistence). No auth is required — same reasoning as the MCP server: loopback, single-user (I5). AprotocolVersionthat is not a positive integer (a bool —True/Falseareintsubclasses in Python and must not silently round-trip as JSONtrue/false— a negative number, a float, or a string) is treated as absent: the response falls back toACP_PROTOCOL_VERSIONrather than propagating the malformed value.session/new(client→agent, request): params{"cwd": str, "mcpServers": [...]}.mcpServersis accepted and ignored (Prism does not host or proxy MCP servers inside its ACP agent — non-goal §4.5). Response:{"sessionId": str}(auuid4hex string). Session state (_Session:sessionId,messages: List[dict],model: str,cancel_event: threading.Event,busy: bool) lives in an in-process dict; it does not survive process restart.modelis resolved once, atsession/newtime, via a new shared helperprism.catalog.pick_default_model(all_models)(extracted from the picking logic duplicated today only inmcp.py::call_prism_server: prefer a CUDA ONNX model, else the first available model), overridable by$PRISM_ACP_MODEL(an unset or empty value keeps the auto-pick). If the catalog is completely empty (no ONNX or Ollama models installed at all) and$PRISM_ACP_MODELis unset,prism.catalog.DEFAULT_FALLBACK_MODEL_ID("Phi-4-mini-instruct-cuda-gpu", the same literalmcp.py::call_prism_serverhas always fallen back to) is used;session/newstill succeeds in this case, and the absence of any real model only surfaces on the firstsession/prompt, as a normal generation error (see below) — there is no separate "no models installed" failure mode atsession/newtime.session/prompt(client→agent, request): params{"sessionId": str, "prompt": [{"type": "text", "text": str}, ...]}. Any content block whose"type"is not"text"(e.g."image","resource","resource_link"— all legal in the protocol but not yet supported here) makes the whole request fail immediately with-32602 invalid_paramsnaming the unsupported type; nothing is sent to the model. A secondsession/promptfor a session whose previous prompt has not yet resolved (busy is True) also fails immediately with-32602 invalid_params("a prompt is already in progress for this session"); the first milestone is one in-flight prompt per session.- The text blocks are joined and appended to
messagesas a{"role": "user", ...}turn (same message-list shape as/v1/chat/completions). - Generation prefers
$PRISM_BASE_URL/chat/completionswith"stream": true(same default URL and$PRISM_API_KEYauth asmcp.py); it parses the SSE stream (data: {...}lines, terminated bydata: [DONE]) and, for every chunk whosechoices[0].delta.contentis non-empty, sends asession/updatenotification (noid, so no response is expected):{"sessionId": ..., "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": <piece>}}}.reasoning_contentdeltas (P7) are forwarded the same way as"agent_thought_chunk"updates, not"agent_message_chunk"— an ACP client that renders thoughts and answers differently gets that distinction for free from Prism's existing reasoning-content separation. - Server unreachable (
urllib.error.URLError, matchingmcp.py's existing fallback condition): falls back toprism.engine.OnnxGenAiEngine.stream_generateon the in-processActiveEngineManager(same fallback the MCP server already uses, same caveat that this bypasses the HTTP server's cross-request serialization — pre-existing behavior, not new to ACP). - Cancellation:
cancel_eventis checked between chunks in both the SSE-reading loop and the direct-engine fallback loop. On seeing it set, the loop stops reading (closing the HTTP connection or the generator), and the worker thread resolves the still-pendingsession/promptrequest withresult: {"stopReason": "cancelled"}instead of"end_turn". If generation had already finished naturally in the race, the"end_turn"response — sent first — wins, and asession/cancelthat arrives after is a silent no-op (idempotent; ACP does not require an acknowledgement forsession/cancel, it is a notification). A cancel that arrives before any content has been generated still appends{"role": "assistant", "content": ""}tosession.messages(rather than nothing at all) — an empty turn, not a missing one, so strict user/assistant alternation holds for the next prompt. - Errors (backend unavailable, resource limits, template render failure, context length exceeded — the same
failure modes
docs/api.mdErrors table lists for/v1/chat/completions) resolve thesession/promptrequest with a JSON-RPC error object,code: -32000,messagenaming the underlying Prism error (HTTP status code and body, or the direct-engine exception text) — not a silently truncated answer. Matches the "no silent fallback" reasoning behind I1 and P11. On any such error, the user turn appended at the start of this prompt is popped back offsession.messagesbefore the error is sent, so the session's history has no unanswered, dangling user turn — the nextsession/prompton the same session starts from the last complete exchange, not from two consecutive user turns (which would break strict alternating-role chat templates).
- The text blocks are joined and appended to
session/cancel(client→agent, notification, noid, no response): params{"sessionId": str}. Setscancel_eventfor that session if it exists and has a prompt in flight; unknown or idle session ids are ignored (idempotent, matches/v1/unload's idempotence style).- Known limitation — cancel is session-scoped, not turn-scoped. ACP's
session/cancelcarries only asessionId, no id correlating it to a specificsession/promptcall. If a client sendssession/cancelfor a turn that has already resolved (the client raced its own nextsession/promptahead of receiving that resolution, or sent a cancel after already reading the response), and a new prompt on the same session is already in flight by the time the stale cancel is processed, the stale cancel takes effect against that new prompt instead of being a no-op. This is only reachable if the client itself sends a newsession/promptbefore it has received the previous one'sstopReason— an ACP client that waits for each turn to resolve before starting the next (the expected pattern) never hits it. Fixing this fully would need a per-turn correlation id, which ACP'ssession/canceldoes not carry; not addressed in this milestone.
- Known limitation — cancel is session-scoped, not turn-scoped. ACP's
- Unknown methods (including
session/load, anyfs/*orterminal/*call arriving from a client that assumes Phase 13/14 capabilities Prism has not advertised) get MCP's existing pattern:-32601 Method not foundwhen the request carries anid; a notification with noidand an unknown method is silently dropped. - Malformed-but-JSON-valid input never crashes the process, and is classified by an explicit contract, not by
exception type.
AcpRequestError(code, message)is raised only at points that have explicitly validated the incoming shape: a non-dictparamsfor any of the four methods, asessionIdthat is not a string (_require_session_id), or apromptthat is not a list of objects with a"text"type (_extract_prompt_text)._dispatchcatchesAcpRequestErrorfirst and sends its carriedcode(always-32602) verbatim. A second, separateexcept Exceptioncatches everything else and always reports-32603— this is deliberately not the same code path: an earlier revision classified by exception type (AttributeError/TypeError/IndexError/KeyError→-32602, else-32603), which is unsound, because a genuine internal bug (e.g. a catalog entry whosedevicefield isNoneinstead of a string) can raise the exact sameAttributeErrora malformed request raises, and would have been mislabeled-32602 Invalid paramsfor a perfectly well-formed request. A request that is not even a JSON object, or a notification with noid, is silently absorbed with no response. This applies to the synchronous handlers (initialize,session/new,session/cancel, and the prompt-extraction half ofsession/promptthat runs before its worker thread is spawned); the worker thread itself (_run_prompt) already has its owntry/except Exception(see the Errors bullet above), so a fault there was already isolated to that onesession/promptrequest. - A pathologically nested (but syntactically valid) JSON line must not crash the process either.
json.loadson a deeply nested structure (thousands of nested[) raisesRecursionError, notjson.JSONDecodeError— a third crash vector distinct from a malformed shape (caught by_dispatch, above) because it happens before_dispatchis ever reached.run_acp_server's per-line body (_handle_line) therefore wraps bothjson.loadsand the_dispatchcall in onetry/except Exception, logging and dropping the line instead of propagating. - A broken stdout never crashes the process either.
_write(the single choke point every response and notification goes through) wraps itssys.stdout.write/flushintry/except (BrokenPipeError, OSError, ValueError), logging to stderr and dropping the line instead of raising — mirrorsprism/server.py's_safe_write(spec P13). Without this, an editor that closed its end of the pipe (crashed, or tore the ACP session down) would take downrun_acp_server's stdin loop on the very next response — including the-32602/-32603error responses_dispatch's own catch-all sends, which would otherwise raise from inside that catch-all and propagate out uncaught. The stderr log line itself is wrapped in its owntry/except Exception: pass— if stderr is also broken (both streams torn down together), there is nothing left to report to, but the write still must not raise. - The lazy
_catalog()/_engine_manager()singletons are safe under concurrent daemon threads. Both are guarded by one module-levelthreading.RLock(_state_lock), not a plainLock:_engine_manager()calls_catalog()while already holding_state_lock, so a non-reentrant lock would self-deadlock the very first time it runs. Two sessions can independently hit the direct-engine fallback on their own daemon threads at the same time (e.g. both seeprism serveunreachable); without the lock, both could pass the "is itNone" check before either finished constructing, producing twoActiveEngineManagers — i.e. two engine locks, defeating I2's "one resident model, one lock" and reaching exactly the concurrent-model-load scenarioAGENTS.mdcalls out as having hung the reference Windows host. - A late failure while reporting a result must not discard a completed answer.
_run_prompttracks whether the assistant turn was actually appended tosession.messages(a localansweredflag) before deciding whether to pop the dangling user turn on an exception. Onlyanswered is Falsepops — if the failure happens after the answer was appended (e.g._send_resultitself raising), the completed answer stays in history and only the response to the client is lost, rather than silently discarding the model's actual answer and leaving the same one dangling unanswered user turn the pop was introduced to prevent, just via a different trigger. - Logging.
logging.getLogger("prism.acp")traces a session's lifecycle bysession_id/req_id, mirroringprism/server.py'slogging.getLogger("prism.server")convention:session/newatINFO(session id, resolved model,cwd);session/promptaccept/reject, start and finish atDEBUG; a fallback to the direct engine (prism serveunreachable) atWARNING, since it is operationally significant even though the request still succeeds; a failedsession/promptatWARNING;session/canceland the per-message_dispatchrouting atDEBUG. The module's existing operator-facing stderr banner/notices (the startup line,_write's dropped-response notice,_handle_line's dropped-malformed-request notice) are kept as plainsys.stderrwrites and now also emit the equivalentloggingcall (WARNINGfor the two drop cases), so every stderr notice has alogging-visible counterpart without changing the stderr output callers may already depend on. No new environment variable or CLI flag — standardloggingconfiguration (handlers, level) applies, same as anywhere else in Prism that uses theloggingmodule.prism/mcp.pygot the equivalent treatment in the same pass:logging.getLogger ("prism.mcp")tracestools/calldispatch andcall_prism_server's fallback/error paths atDEBUG/WARNING, thoughmcp.pypredates Phase 12 and is otherwise unrelated to it. prism connect acp: mirrorsprism connect mcp --target ...— prints (and with--write, merges into) a Zedsettings.jsonagent_serversentry pointing atprism acp(no network config needed; ACP is stdio-launched by the editor, like MCP).docs/integrations.mdgets an "ACP (Zed)" section next to "MCP server" documenting the table of methods above and the connector command.- Not yet planned (tracked in
plan.mdPhase 3 backlog, gated byintent.mdnon-goal §4.5 until promoted):terminal/*command execution (Phase 14). fs-mediated tool calls (fs/read_text_file,fs/write_text_file,session/updatetool_call/tool_call_update, andsession/request_permissionbefore a write) are promoted to Phase 13 below. - No invariant changes (I1–I8 still hold: I2's serialization is unaffected because ACP goes through the same
ActiveEngineManager/HTTP path as every other client; I4 stays stdlib-only — no ACP SDK dependency, hand-rolled JSON-RPC likemcp.py). Stdlib-only. - Docs:
docs/integrations.mdnew "ACP (Zed)" section;docs/cli.mdnewprism acpandprism connect acpentries (enforced bytests/test_docs.py);docs/getting-started.mdEnvironment table gets a row for$PRISM_ACP_MODEL.CHANGELOG.md [Unreleased]gets a### Addedentry.
Phase 13: ACP fs-mediated tool calls — read_file, write_file (plan.md Phase 13)¶
- Scope: this phase makes
prism acpadvertise two model tools —read_file(path)andwrite_file(path, content)— and execute them by mediating the ACP client'sfs/read_text_fileandfs/write_text_fileJSON-RPC methods. The ACP agent itself never opens a file or writes to disk; every byte goes through the client. This activates intent.md §3.7 and is the first load-bearing use of new invariant I9. - Tools advertised to the model (filtered by
clientCapabilities.fs.*captured duringinitialize): read_file(path: str)→fs/read_text_filewithparams={"sessionId", "path"}(optionalline,limitfrom the model arguments when present). Result{content: str}is fed back to the model as arole:"tool"message.write_file(path: str, content: str)→session/request_permission(always — see below), thenfs/write_text_filewithparams={"sessionId", "path", "content"}onallow_once. Result is{}on success.- Tool definitions follow the OpenAI function-calling JSON-schema shape (
{"type":"function","function":{"name","description","parameters"}}) soprism.templates.render_prompt(resolved, messages, tools)and the existing OpenAI-compatible server path accept them verbatim. - Capability negotiation:
- During
initialize, captureparams.clientCapabilities.fs.readTextFile(defaultFalse) and.writeTextFile(defaultFalse) into a per-connection map. Store on the_Sessionso they survivesession/new. IfreadTextFileisFalse,read_fileis omitted from the tool list; ifwriteTextFileisFalse,write_fileis omitted (no point advertising a tool that can never succeed). If both are absent, no tools are advertised — the agent runs as in Phase 12. - If the client does not advertise
clientCapabilities.fsat all (older clients), treat both capabilities asFalse. - Tool-call loop in
_run_prompt: - Replaces the Phase 12 single-shot flow with a bounded loop, max iterations
MAX_ACP_TOOL_ROUNDS(default8, non-configurable in v1). The model request re-renders with the same tool list and the newtoolmessage appended. - On each loop iteration:
prism.tools.parse_tool_calls(text)extractstext(forwarded asagent_message_chunk) andcalls(forwarded assession/updatetool_call). If no calls, resolvesession/promptwithstopReason:"end_turn". If calls exist, execute them in order (see permission flow below), appendrole:"tool"messages with the result, emittool_call_updatewithstatus:"completed"or"failed", then iterate. - On exceeding
MAX_ACP_TOOL_ROUNDS, resolve withstopReason:"end_turn"after a finalagent_message_chunksummarizing "tool loop budget exhausted". - Notification shape:
tool_call:{sessionUpdate: "tool_call", toolCallId: <id>, title: <human-readable>, kind: "read"|"edit", status: "in_progress"}.kind:"read"forread_file,kind:"edit"forwrite_file(matches ACPToolKind).tool_call_update(success):{sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: [...]}with one{"type":"content","content":{"type":"text","text": <file content or short success note>}}entry.tool_call_update(failure): same shape withstatus:"failed"and a content entry carrying the error text.toolCallIdis a per-session monotonic id (f"call_{session_id}_{n:04d}").- Permission flow (
session/request_permission): - Sent before every
write_file(per Phase 3 backlog text: "permission before a write"). The spec language is "MAY request permission" but the agent always does forkind:"edit"tools; reads andkind:"think"are auto-allowed. - Request params:
{sessionId, toolCall: {toolCallId, title: "Write {path}", kind: "edit"}, options: [{optionId: "allow_once", name: "Allow once", kind: "allow_once"}, {optionId: "reject_once", name: "Reject", kind: "reject_once"}]}. - Response handling:
{"outcome":{"outcome":"selected","optionId":"allow_once"}}→ proceed withfs/write_text_file.{"outcome":{"outcome":"selected","optionId":"reject_once"}}→ fold"permission denied"into the tool result, do NOT callfs/write_text_file, mark the tool callstatus:"failed". The model can react.{"outcome":{"outcome":"cancelled"}}→ resolvesession/promptwithstopReason:"cancelled"(the prompt turn has been cancelled by the client).- Any JSON-RPC
error→ mark the tool callstatus:"failed", fold the error message into the tool result.
- Bidirectional JSON-RPC over stdio (
prism/acp.py): - The Phase 12 main loop is read-only (it answers client requests). Phase 13 adds an outbound request path:
_send_request(method, params) -> dict | raisesthat assigns a monotonicid(per-process counter), writes{"jsonrpc":"2.0","id":N,"method":M,"params":P}to stdout, and parks athreading.Event-and-result future in a module-level dict keyed by id. - The main read loop, on receiving a message whose top-level
idis in the pending dict, sets the future's result and removes the entry; a JSON-RPCerrorraises a typed exception (AcpClientError(code, message, data)) so callers can distinguish a client-side error from a transport error. Notification frames (noid) and request frames (haveid, expect a reply) are dispatched as today; response frames (haveid, nomethod) now wake the pending future. - Pending futures are cancelled (with a clear error) if the connection drops before the reply arrives.
- Capability-mismatch fallback: if a tool call arrives for a capability the client did not advertise (defense-in-depth — the
capability filter on advertised tools is the primary defense), the agent returns a
role:"tool"message of{"error": "fs.readTextFile is not advertised by the client"}and emitstool_call_updatewithstatus:"failed". The model can adapt instead of producing a JSON-RPCerrorto the client. - Cancellation: between any two loop iterations and immediately before each
fs/*andsession/request_permissionround trip,cancel_event.is_set()is checked. If set, resolvesession/promptwithstopReason:"cancelled"and discard the pending future. - Failure modes:
fs/read_text_filereturns{"error":{...}}→ JSON-RPC error → tool result message carries the error text; tool_call_updatestatus:"failed"; loop continues.fs/read_text_file/fs/write_text_filereturns successfully with non-schema-conforming data → tool result message carries a short diagnostic;status:"failed".session/request_permissionreturnscancelled→stopReason:"cancelled", no further model calls in this turn.cancel_eventset during a_send_requestround trip → pending future is cancelled;session/promptresolves withstopReason:"cancelled".- Tool loop exceeds
MAX_ACP_TOOL_ROUNDS→stopReason:"end_turn"after a final summary chunk (not an error). - Environment: no new env vars in v1 (
MAX_ACP_TOOL_ROUNDSis module-level). Future work may add$PRISM_ACP_MAX_TOOL_ROUNDSto let operators tune the budget; Phase 13 keeps it constant. - Constraints upheld:
- I1 (device honesty): unaffected — same model resolution path.
- I2 (one resident model, one lock): unaffected —
fs/*round-trips do not touch the engine. - I4 (no runtime deps): unaffected — stdlib only (
threading.Event,json,itertools.count); no new imports. - I5 (safe defaults): unaffected — ACP is stdio only.
- I6 (MCP never touches files): unaffected — applies to
prism mcp. - I9 (new): ACP agent never touches user files or runs commands. First load-bearing use; tests assert this by faking
open()andsubprocessand confirming neither is called from any code path reachable byprism acptool execution (test:tests/test_prism_acp.py::TestAcpAgentNeverTouchesFiles). - I8 (hermetic tests): all
tests/test_prism_acp.pytests for Phase 13 mock_send_requestand_server_delta_stream; no hardware, no network, no model files. - No invariant violation beyond the explicit addition of I9 (which formalizes intent.md §3.7).
- Docs:
docs/integrations.md"ACP (Zed)" section gains a "fs-mediated tool calls" subsection listing the two tools, thesession/updatetool_call/tool_call_updateshapes, and thesession/request_permissionUX;CHANGELOG.md [Unreleased]gets a### Addedentry for Phase 13;tests/test_docs.pyenforces the docs link.
Phase 14: VRAM telemetry in prism doctor (plan.md Phase 14)¶
- P17 Per-device VRAM in
prism doctor, low-VRAM warning: - Display: when
prism.telemetry.get_gpu_info()reportsavailable: True,prism doctorprints one line per device with VRAM used / total / free in MiB, matching the existingprism statusline (prism/cli.pycmd_status). When NVML is missing or the GPU is not detected, the existing❌ GPU: ...branch is unchanged. - Warning: when a device's
vram_free_mbis below the configured threshold,prism doctoralso emits exactly onelogging.getLogger("prism.cli").warning(...)record per affected device, namingGPU #N, the livevram_used_mb / vram_total_mb / vram_free_mb, the threshold, and the next step ("release the resident ONNX model withPOST /v1/unloadto recover headroom"). - Threshold: a new helper
prism.resources.low_vram_threshold_mb()reads$PRISM_VRAM_RESERVE_MBexactly likeprism.resources.check_can_loaddoes today (defaultDEFAULT_VRAM_RESERVE_MB= 1536.0; empty / non-numeric falls back to the default, matching the existingos.environ.get(...)pattern). No new env var, no new flag, no new dataclass field — the existingEnvConfig.vram_reserve_mbfield (added by Phase 11) is the same surface, simply read through the same local helper. - Why:
prism doctoris the read-only "what is wrong with my setup" command (intent.md§5, "prism doctornames the cause when CUDA cannot be used"). It already surfaces the missing NVML driver, the missing.wslconfigmemory limit, the missing CUDA libraries, and the missing Ollama daemon. Free VRAM below the safety reserve belongs in the same diagnostic surface — silently continuing without raising it is the kind of "quiet hardware fallback"intent.md§3.3 calls out. - Failure modes: none.
prism doctorexits 0 regardless of VRAM state; a low-VRAM device is a warning, not an error. The warning does not block any laterprism run/prism serve/ model load; the existing pre-load capacity check (prism.resources.check_can_load) is the only refusal path. - No invariant change (I1–I9 hold). No new env var. No new HTTP endpoint. Stdlib-only.
- Docs:
docs/cli.md"prism doctor" gains a bullet for the new per-device line and the low-VRAM warning;CHANGELOG.md [Unreleased]gets a### Addedentry.docs/api.mdis unaffected.
Implemented (Phase 1: Parallel use must not exhaust the machine)¶
- P1 Resource budget (
prism/resources.py): before an ONNX model is loaded,check_can_load(model_path, device)compares free RAM (always) and free VRAM (cudaonly) with the estimated need (weights plus prefill headroom ofPRISM_PREFILL_CHUNKx about 1.4 MB per token) and a reserve. It raisesInsufficientResourcesError, aModelLoadError, whose message gives free versus needed memory, which Prism process holds the memory, and how to override. Environment:PRISM_VRAM_RESERVE_MB(1536),PRISM_RAM_RESERVE_MB(2048),PRISM_RESOURCE_CHECK=off. - P2 Cross-process load lock (
prism/machine_lock.py):flockon~/.prism/load.lock, held only while checking and loading (never during generation). A second process waits up toPRISM_LOAD_TIMEOUT(120 s) and then fails naming the holder's PID and model. The lock vanishes with its holder. - P3 Entry point:
OnnxGenAiEngine._load_modeldoeslock; check; load, soserve,mcp,run,chatandbenchmarkare all covered. A refusal never calls the library's model loader. - P4 HTTP:
InsufficientResourcesErrorbecomes503 insufficient_resourceswithRetry-After; more than--max-queue/$PRISM_MAX_QUEUE(default 8) waiters get503 server_busyimmediately. - P5 Diagnostics:
prism statusshows RAM and swap; a load logs the VRAM and RAM delta;prism doctorwarns, read-only, when a Windows.wslconfighas nomemory=orautoMemoryReclaim=gradual. Prism never writes.wslconfig. - P6 Thread control (
PRISM_THREADS): A positive integer setting intra-op thread count for ONNX models. When set,_model_foroverlaysmodel.decoder.session_options.intra_op_num_threads. Unset (default) leaves thread allocation to the engine. - Not covered: Ollama (GGUF) shares the GPU and bypasses the guard;
vram_free_mb()still sees its usage.
5. Development tooling contract¶
- Gate
python3 scripts/sdlc_check.py [--only compile|tests|changelog|docs|lint ...] [--base REF]:compilebyte-compilesprism,foundry_wsl,tests,scripts;testsruns the unit suite;changelogrequiresCHANGELOG.mdto change when runtime code (prism/,foundry_wsl/) changed against--base(defaultmain);docsrunsmkdocs build --strictand is skipped when MkDocs is not installed;lintis described below. Exit0unless a check failed.changed_filesreturns paths verbatim (NUL-separated). - Lint (
lintcheck, ruff, configured inpyproject.toml[tool.ruff]): runsruff check --force-excludeon the changed.pyfiles that still exist and reports only the violations that touch a changed line (a hunk ofgit diff -U0against the merge-base with--base; every line of an untracked file). Violations in code the change did not touch never fail the gate, so the unformatted history is not a debt the next author pays.passwhen nothing is reported or no Python file changed;skipwhen ruff is not installed (looked up asruffonPATH, then.venv/bin/ruff, thenpython -m ruff) or git cannot diff against--base;failon a violation on a changed line (onepath:line:col CODE messageper line) or when ruff itself errors (exit2, its stderr is the detail). The check never modifies a file (no--fix, noruff format). It is not part of.githooks/pre-commit; CI runs it on pull requests. - Lint hook
scripts/lint_hook.py, wired as the Claude CodePostToolUsehook forEdit|Write|MultiEditin.claude/settings.json: reads the hook JSON from stdin, and for atool_input.file_paththat is an existing.pyfile inside the repository runs the same check restricted to that file. Exit2with the report on stderr when it fails, so the agent sees it; exit0for everything else (not a Python file, outside the repository, nothing to report, ruff missing, invalid JSON). A hook must never block an edit for a reason that is not a lint finding. - Red-first
python3 scripts/sdlc_check.py --red TEST_ID...: exit0only if every named test fails or errors now. A passing, skipped,expectedFailure, timed-out (60 s) or non-existent id is not red. It prints the exception line of each failure, swallows test output, and cannot be combined with--only/--base. - Pre-commit hook
.githooks/pre-commit(opt-in:git config core.hooksPath .githooks) runscompile,testsandchangelogfrom the repository root and blocks the commit when one fails. - CI runs the changelog and lint checks on every pull request in addition to tests, packaging and docs.