Personal project · Aug 2026 — present
Personal AI OS
A local-first agent platform, and the discipline to prove what it does
A model-agnostic personal agent system running entirely on a consumer GPU — no cloud provider anywhere in the codebase — with a purpose-built evaluation harness, a protected holdout split, and 69 architecture decision records including the ones that killed my own hypotheses.
- tests · 1,042 offline
- 1,058
- decision records
- 69
- eval cases · 14 suites
- 85
- committed benchmark runs
- 380
- runtime dependencies
- 3
- VRAM ceiling
- 8 GB
01
The constraint is the point
Everything runs on Ollama on my own machine — an RTX 3050 with 8 GB of VRAM. There is no cloud model provider in the codebase, not as a default and not as a fallback, and the package config names the libraries that are forbidden from ever being added.
That is not asceticism. A hosted fallback fails open: the system looks healthy right up until the subscription lapses, and every weakness the local model had was masked instead of fixed. The scoping rule is blunt — if it does not work on a 7B running on a consumer GPU, it is not finished.
The whole runtime has three dependencies: pydantic, httpx, pyyaml.
02
The orchestrator is not a new layer
The Master agent is an ordinary agent whose manifest lists exactly one tool: delegate. Orchestration therefore reuses the same loop as everything else, and inherits the permission gate, the tracing and the failure recovery unchanged rather than reimplementing them.
"Prefer delegation" is enforced by what the agent can reach, not by a prompt asking it to behave. Delegation depth and the call stack are injected by the runtime inside a closure, so a tool chooses which agent to call and nothing else — it cannot fabricate a shallower depth to escape the bound.
$ paios run master "Add a task to renew my passport, then tell me what's on my list."
[runtime] agent master -> qwen2.5:7b-instruct (role 'reason' -> tier 'medium')
[runtime] master -> delegating to task_agent (depth 1)
I've added "Renew my passport" to your list. You now have three tasks:
...
[ok] agent=master model=qwen2.5:7b-instruct iterations=2 tool_calls=1
trace: paios trace 63847bb8
▋03
One gate, one call site
Exactly one function in the codebase is allowed to execute a tool. A unit test parses the abstract syntax tree of every file in the source tree and fails if a second call site ever appears.
That matters because the permission broker is the only thing standing between a small model and the filesystem. A bypass introduced by a future refactor fails in pytest, not in production.
Two authorization gates ask the model nothing at all. A write is authorized only if the user's own turn requested that kind of write; a fetch is authorized only if the URL appears literally in the user's objective. Both exist because I measured the alternative — twice — and found that adding a prompt clause relocates injection compliance rather than reducing it. The model is the component being defended, so it cannot also be the thing enforcing the defence.
$ paios trace 63847bb8 -v
5 tool.requested
6 permission.decision
7 delegate.start
9 model.request <- task_agent's own work, nested
13 tool.result
16 delegate.end
17 tool.result <- back in master
▋04
Measuring instead of claiming
Fourteen suites, 85 cases, a protected holdout of 12, 29 registered checks and a 16-code failure taxonomy. Scoring is deterministic — there is no LLM judge — and a result is a pass rate over N runs, never a boolean. All 380 result files are committed, each stamped with the git SHA and the Ollama version that produced it.
A studied case is considered spent and retired from the holdout. Predictions are written down before the arms are run. And after one experiment published "hypothesis rejected" from an arm that turned out to be byte-identical to its control, a rule was added: verify your manipulation actually happened.
The 7B is 89% defect-free once adjudicated — raw and adjudicated numbers are published side by side.
A false-positive instrument. Low scores are the design, not a regression.
05
Three findings that changed the system
The harness found a real defect within an hour of existing, one that every unit test passed. Asked to complete "the oat milk task", both models guessed a task id, completed the wrong task, and described it fluently. The structural fix took that case from 0/5 to 5/5.
A claim I had repeated across four documents for a month — that the 3B model returns an empty response — was simply false. Instrumenting the provider payload showed all 83 of those runs emitted between 25 and 79 completion tokens. My own runtime was discarding them. The real cause turned out to be upstream of my code entirely: Ollama renders the tools block as a Go struct rather than JSON, so the model infers a tool name, puts the agent name in it, and Ollama discards the call as naming a nonexistent tool. Replaying the identical rendered prompt through the raw endpoint produced 15 valid tool calls out of 15, against 0 out of 15 through chat.
And adding a sixth agent to the orchestrator's roster tripled the silent-turn rate — 43/55 with five agents, 34/65 with six, then 49/65 with zero empty turns when I put it back to five nine minutes later. The agent worked. It was withdrawn anyway, because the roster length was costing more than the agent was worth.