ph-core
Everything a pH profile is made of: the plugin framework, the append-only log, the ReAct loop, the capability seams — and the seventy-four rows that provide them.
There is no privileged core to patch (invariant I1). What this distribution ships is the vocabulary a profile is written in — a context that holds services, an event bus, scopes that unwind what they created — plus one row per thing pH can do, each addressable by id from a YAML document. The agent loop is a row. The session store is a row. The filesystem is a row. So is the tool registry that decides whether a tool call is even allowed to happen.
phern --profile headless -p "hello" # ph-base + the fake adapter
phern config --profile base # every knob every row accepts
phern doctor --profile base # what actually activated
ph-core is a dependency of every other package in this workspace and depends
on none of them.
What is in here
| module | what it owns |
|---|---|
ph.cordis |
the plugin meta-framework subset (D1): Context, service keys, the four event dispatch modes, scopes whose disposal unwinds every registration (I2), and the YAML profile loader |
ph.session |
the append-only event log, the derived model surface (derive_messages), the human transcript(), and the folds over both |
ph.llm |
the provider-neutral vocabulary, the stream assembler, the ctx.llm seam, plus the fake and replay adapters |
ph.agent / ph.agent_loop |
the agent handle and its inbox; the ReAct driver, mounted as a row like anything else |
ph.tools |
the registry, the governed pipeline (tools/pre-execute, tools/post-execute, guards), the batch scheduler, and Code Mode's transport and generated SDK |
ph.seams |
one module per capability seam: the Protocol, the local provider, and what it refuses |
ph.system_prompt |
prompt assembly — cached sections, post-cache context() snapshots, and AGENTS.md discovery |
ph.persistence |
JSONL and Turso backends, checkpoints, leases, lineage and crash repair |
ph.commands |
the slash commands ph-core itself owns |
ph.bundles |
base.yaml, headless.yaml, and the ph.bundles entry-point group other distributions register into |
ph.paths |
the three roots ($PH_HOME, $PH_CACHE, $PH_RUNTIME) and their resolution rules |
ph.testing |
builders, stubs and fixtures a test stands a profile up with. Nothing shipped imports it |
The two bundles it ships
ph-base (src/ph/bundles/base.yaml) is the shared core of every pH
profile: the log, the loop, the tool registry, every capability seam with its
local provider, the built-in tools, durability, resilience and the runtime
invariant rows. ph-headless adds one row — the scripted llm-fake
adapter — so a one-shot or a scenario test can script a conversation without
touching code.
Both are paths, not entry points, because ph-app can import them directly.
Every other bundle in this workspace is discovered through the ph.bundles
entry-point group, which is what lets ph-app compose the rlm profile without
depending on ph-rlm:
[project.entry-points."ph.bundles"]
rlm = "ph_rlm:BUNDLE"
The tools the model gets
Registered by rows, so a profile decides which of them exist at all.
| row | tools | note |
|---|---|---|
tool-fs |
read, write, edit, glob, grep |
thin shells over ctx.fs, so the policy gates and the workspace root apply to any second editing tool too |
tool-bash |
bash |
goes through ctx.shell, which is what sandbox-local confines |
tool-attach |
attach |
puts an image, audio file, video or PDF from the workspace in front of the model's own eyes. Registers nothing without an attachment store |
tool-ask-user |
ask_user |
ships disabled: a question with nobody to answer it spends a turn on nothing. tui.yaml arms it |
subagent-task |
task |
blocking delegation. Registers nothing until a ctx.subagents provider is mounted — a tool named in every prompt and refused on every call teaches the model a capability the deployment does not have |
skills-progressive |
skill |
the catalog goes in the prompt; a body only when the model asks for it by name (G9) |
The commands a person can type
These are commands, not tools: a person asks the harness directly, it costs
no model turn, and the log records command/run/command/done rather than the
model having decided something the user decided.
| command | row | what it does |
|---|---|---|
/sandbox |
sandbox-commands |
show what confined commands may reach, and change it without a restart |
/workspaces |
workspace-commands |
list, export, merge or remove the branches agents left behind in this session |
/revert |
workspace-revert |
restore this agent's workspace to a per-run checkpoint |
/autonomous |
autonomous |
work toward a goal until its gates pass or a budget stops it |
Adjusting it from a profile
Three layers, applied in this order — and a patch replaces the targeted row's whole config rather than merging into it, so a row's effective value is always one layer's and readable in one place:
- the shipped documents —
ph-base, then whatever the profile layers; - your overlay,
$PH_HOME/profiles/<name>.yaml; - drop-ins pH wrote on your behalf,
$PH_HOME/profiles/<name>.d/*.yaml, in name order (this is where/sandbox allow …keeps its decisions); --patch, this run only, same grammar as a profile document.
A patch entry is {id: …, config: {…}} to reconfigure, {id: …, disabled: false} to arm a row a bundle ships off, {id: …, remove: true} to drop one, or
{insert: [...]} to add one. A list in row config is replaced, not merged —
an overlay restating one field of a route must restate the whole route entry.
phern --patch '{id: fs, config: {root: /tmp/scratch}}' --profile tui --mode tui
phern --patch '{id: tool-ask-user, disabled: false}' --profile headless -p "..."
phern --dump-config --profile llama # the composition, before anything runs
phern config --row containment --profile tui # one row's knobs, defaults and what the profile set
The knobs most deployments touch
| row | config | default |
|---|---|---|
agent-loop |
maxParallelToolCalls |
10 |
tools |
mode: native | code |
native |
tools-code-mode |
maxDispatchesPerRun, maxSubagentSpawnsPerRun, maxParallelSubCalls |
256, 32, 10 |
fs-local |
root, ignore |
the session cwd |
sandbox-policy |
defaultMode: read-only | workspace-write |
read-only (tui.yaml sets workspace-write) |
sandbox-allow |
paths, network.{mode,hosts} |
an allowlist over the package indexes and documentation hosts in DEFAULT_HOSTS |
containment |
tier, childTier, strict |
unset — which is not advisory: unset means nobody chose, so a tier provider a profile layered is used |
workspace-lifecycle |
access, provision |
write |
jobs-local |
concurrency (per job kind) |
unset |
llm-retry |
maxAttempts, baseDelayMs, maxDelayMs, countAllRetries |
3, 500, 20000, false — only llm-retry's own retries spend its budget; true counts every row's, compaction's included |
session-telemetry |
enabled, path |
enabled: false in ph-base |
subagent-presets |
presets |
empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
skills-progressive |
paths |
empty on purpose — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
autonomous |
maxContinuations, maxTurns, maxTokens, timeoutMs, tokenSources |
3, 12, 80000, 1800000, [own] — add compaction and children to charge summaries and subagents to maxTokens |
subprocess-local |
scrub, keep, maxOutputBytes |
8388608 |
tool-attach |
maxBytes |
33554432 |
An entry carrying both id: and name: is a row, not a patch — that is the
rule that decides which of the two a document line is. So swapping a provider
is a removal and an insertion, not a rename; consumers never learn which one
answered either way (I5):
# $PH_HOME/profiles/tui.yaml — keep the log in Turso instead of JSONL
- id: session-persistence
remove: true
- insert:
- id: session-persistence
name: session-persistence-turso
Giving the existing id a new name: in place looks like it should work and does
not: it appends a second row, and the mount then refuses with
service "session_persistence" is already provided.
Seams that ship with no provider, deliberately
ph-base mounts the definition of several seams and no backend, because these
have genuinely different answers per deployment and a harness that shipped one
would have made the choice for you:
code_runtime— nothing runs model-written code untilph-rlmmountscode-runtime-python(or a profile mountscode-runtime-stub).subagents— no child-agent provider, which is whysubagent-taskregisters no tool inph-base.compaction— the seam records and replaces; when and what to say areph-stabilize'scompaction-summarize. Every profileph-appoffers layers that bundle when it is installed, so a profile compacts;ph-basecomposed on its own still does not.uploads— mounted with no uploader; each adapter row registers its own, so a profile with no file API sends every byte inline.
A profile that layers nothing here simply never compacts, never delegates and never runs code. That is the plain harness, and it is a supported posture.
Limitations, and things that are deliberate
- No Textual, Rich, Typer,
ph_app, aiohttp, textual-serve or jinja2 imports —tests/test_layering.pyfails the build on any of them. A front end is a consumer of this package, never the other way round. - No real model wire ships here.
llm-fakeandllm-replayare for wiring work and tests; Anthropic, Google and the OpenAI-compatible route are rows inph-app, so a headless deployment that wants a real provider layers one of its profiles. sandbox-localis Linux (bwrap) and macOS (Seatbelt). It probes both claims at mount — a write outside the workspace must fail, and aCONNECTthrough the egress shim must reach the proxy and be refused — and on a host that cannot confine it says so inphern doctorand declines rather than pretending.permissions-fs-style path rules are not here — they areph-stabilize, and they bound seam-mediated access only. A model-authoredopen(path, "w")inside a code cell never fires an intent; what bounds that is the sandbox rung below the rules (E9, N1).- Nothing watches the filesystem.
AGENTS.mdis re-read as a post-cache snapshot each turn, which is what makes an edit take effect in the turn after it; nothing else polls. - Row order carries no load semantics. Activation is service-availability
driven, so a row waits for whatever it injects regardless of where it sits.
The grouping in
base.yamlis for readers.
Tests
tests/ — 75 modules, the largest suite in the workspace. Three worth knowing
about, because they enforce rules rather than behavior:
test_layering.py— the forbidden-import rule above;test_keys.py— everyctx.provide(...)in this package has a typed key inph.keys, and every key has a provider, held against each other in both directions;- the invariant rows (
agent-loop-invariant,session-invariant,tools-invariant,skills-invariant,scope-invariant) are checked at runtime, not only in CI — a harness that can only prove these in a test cannot prove them about your session, andphern doctorreports which hold.
Release files for ph-core 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ph_core-0.3.0.tar.gz | 1.1 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ph_core-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.8 MB
Release files / ph_core-0.3.0.tar.gz
| Download URL | ph_core-0.3.0.tar.gz |
|---|---|
| Size | 1.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
73dbacd71c63986054d6183df16223e9c39d3ed54dfba9f55b2e0f89c230988b
|
|
BLAKE2b-256 checksum How to use checksums |
04c0f54f830c58d16694d27b050abf483bedfca2a888e36f576e3c3321f1e607
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / ph_core-0.3.0-py3-none-any.whl
| Download URL | ph_core-0.3.0-py3-none-any.whl |
|---|---|
| Size | 771.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
90159e58891ba056e57d3dd86f44d602003484194dbcca221f28bdd0610b6a25
|
|
BLAKE2b-256 checksum How to use checksums |
271f7c8ebc52bd23d6d11bd49829766bf2922caaaf6eb144ffd9ad2a7b90217b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|