ph-stabilize
The things that keep a long session from falling over: a plan, offloading in both directions, compaction, limits, a human in the loop, and path rules — each a row on a seam that already exists.
Deep Agents' stabilization features are algorithms plus prompts, not runtime,
so none of them lands as a parameter on the agent loop. Todo planning is a tool
plus a prompt section. Offloading is a tools/post-execute listener.
Summarization is agent/pre-step. The driver does not know any of them is
there — which is the whole integration thesis (D12), and the reason a deployment
that wants the plain harness gets the plain harness.
phern --profile rlm-stable --provider llama --model <model> --mode tui
The package registers the stabilize bundle (src/ph_stabilize/bundle.yaml),
which ph-app composes through the ph.bundles entry-point group without
depending on this distribution.
Every profile ph-app offers layers it — base included — as an optional
bundle: an install without this distribution composes the same profiles and
simply never compacts. Compaction is the row that earns that, because a session
that grows until the provider refuses it is a defect in any posture; the rest
come along because a bundle is the unit a profile can name, and they are inert
until configured. phern doctor reports which of them actually activated.
The rows
| row | what it does | shipped |
|---|---|---|
tool-todo |
write_todos, the todo/write event, the prompt section, and the one-call-per-turn rule |
off |
skill-steps |
a SKILL.md that declares steps: becomes work the turn must finish |
off |
tool-result-offload |
a result over the threshold goes to the spill store; the model gets a head-and-tail preview and the path | on |
input-offload |
the other direction: a pasted build log or dumped table | on |
compaction-summarize |
the conversation replaced by a summary when it stops fitting | on |
command-compact |
/compact, the human verb for the same thing |
on |
limits |
model-call, tool-call and child ceilings, plus a consecutive-failure breaker | on, every ceiling unset |
hitl |
a person between the model and what it cannot take back | on, asking about nothing |
permissions-fs |
path rules over ctx.fs |
on, with one default write rule |
"On" mostly means "inert." Layering this bundle must not, by itself, change
what a deployment does: limits mounts with every ceiling unset, hitl with an
empty interruptOn, permissions-fs with rules that allow everything inside
the workspace. A harness that starts asking on first run teaches its user to
approve without reading, and a limit nobody chose is a limit that fires on
somebody's longest legitimate turn.
The two that ship off are off for a different reason: they hand the model a
tool or keep a turn going, and that is a posture a profile chooses rather than
inherits. rlm-stable is the profile that chooses it — and the reason the
bundle reaching every profile does not thereby give every deployment a todo
tool.
The one row that is not inert on arrival is permissions-fs: it ships a
rule sending writes outside the agent's workspace to interrupt. That is E6's
intended default rather than an oversight, but it is a behaviour change a
deployment should know it inherited — phern config --row permissions-fs prints
what is in force.
The tool, and the command
write_todos(rowtool-todo) — the whole list, replaced. The list lives in the log and nowhere else, so the TUI sidebar and the model's own view are one projection rather than two that can disagree, and it survives a resume and a fork for free. Two deliberate forks from upstream:requiresdeclares what a step waits on (upstream works around the gap in prose, which is a dependency graph nothing can read or check), and a completed entry carriesworked— the number of tools the harness saw run while it was being finished — which is the one field the model does not write, because a receipt the claimant issues is not a receipt./compact(rowcommand-compact) — replace older history with a summary, optionally told what you are about to work on. A command, not a turn: it dispatches directly, recordscommand/run/command/done, and insists the agent is idle first, because a compaction landing mid-turn would move the surface underneath a request the loop had already derived.
Every failure is a sentence rather than a traceback — CompactionError.code is
a closed set precisely so a front end can phrase each one.
Including it, and adjusting it
rlm-stable is rlm plus this bundle plus the rows it arms. Without a profile
that names the bundle, insert the rows you want by name — they are ordinary
plugin entry points, so this works against any profile:
phern --patch '{insert: [{id: tool-result-offload, name: tool-result-offload}]}' \
--profile llama -p "…"
# $PH_HOME/profiles/rlm-stable.yaml — ceilings, and a narrower file rule
- id: limits
config:
modelCalls: {turnLimit: 40, sessionLimit: 400, exit: end}
toolCalls: {turnLimit: 100, perTool: {bash: {turnLimit: 20}}, exit: continue}
breaker: {consecutiveFailures: 5}
- id: permissions-fs
config:
rules:
- operations: [read]
paths: ["**/.env", "**/*.pem"]
mode: deny
- operations: [write]
paths: ["**"]
scope: outside-workspace
mode: interrupt
| row | config | default |
|---|---|---|
tool-result-offload |
tokenLimit, maxInlineBytes, excludedTools |
20000; None disables offloading |
input-offload |
tokenLimit |
50000 |
compaction-summarize |
auto, triggerFraction, keepFraction, triggerTokens, keepMessages, maxTokens, summaryInputTokens, truncateArgs, overflowClipTokens |
0.85 / 0.10 of a known window; 170000 tokens / 6 messages when it is not — upstream's numbers |
limits |
modelCalls (exit: end | error), toolCalls (plus perTool, exit: continue | end | error), children, breaker.consecutiveFailures — each budget takes turnLimit and sessionLimit |
all ceilings unset; breaker 5 |
hitl |
mode (manual | auto | yolo), declared, interruptOn |
auto, nothing configured |
permissions-fs |
rules — each operations, paths, scope (anywhere | outside-workspace), mode (default deny), description |
one rule: write anywhere outside-workspace → interrupt |
tool-todo, skill-steps, command-compact |
— | no configuration |
Writing a hitl rule
Keys are tool names, and each takes a preset, a when: list of regexes, a
description and an allowed decision set. rlm-stable's own rules are the
worked example:
- id: hitl
config:
interruptOn:
bash:
preset: destructive
when: ["\\b(npm|pnpm|yarn|uv|pip)\\s+publish\\b", "\\bsudo\\b"]
description: >-
This command changes something a worktree cannot undo.
run_code:
preset: destructive
when: ["\\bsubprocess\\b"]
allowedDecisions: [approve, reject]
Three things that rule says, spelled out:
- Name the preset rather than retyping it.
destructiveis the set this package ships and tests. The first draft ofrlm-stable.yamlretyped a subset and had already widenedgit pushfrom force-only to every push, against a shipped test pinning an ordinary push as ordinary — one security judgement beats two that disagree. - Key Code Mode on the reserved name. The registry renames the transport
to whatever the presentation calls it (
ipython, in therlmbundle) andhitlresolves that. Keying on the presented name would not turn the gate off — the tool declaresis_irreversible, sodeclaredstill catches it — but thewhen:additions are keyed by name and would silently stop applying. - Four decisions, not two.
approveandrejectwere always reachable; this row addsedit(run it with these arguments instead) andrespond(do not run it — tell the model this), because stopping a turn to say "wrong path" or "you don't need that, the answer is X" costs a round trip that answering in place does not.allowedDecisionsnarrows the set — a program is not something to hand-patch in a modal, sorun_codeabove dropsedit.
The classifier parses; it does not pattern-match. preset: destructive runs
ph_stabilize.destructive, which reads each string argument in its own dialect
— shell through shlex, SQL as statements, Python through ast — and judges
the structure. The twelve regexes it replaced were run over the call's
arguments rendered as JSON, so a real newline became the two characters \ and
n and every \b-anchored pattern stopped matching on a second line: rm -rf,
DROP TABLE, shutil.rmtree and curl | sh were all ungated inside a
multi-line cell, which is every run_code cell. Nothing failed; the gate
simply did not fire. A when: list is still regex, as the deployment's own
escape hatch, but it is matched against the decoded strings.
Limitations, and things that are deliberate
permissions-fsbounds seam-mediated access only (E9, N1). The rules attach toctx.fs's intent waterfalls, so any tool going through the seam is covered without this module knowing a tool name — and a model-authoredopen(path, "w")inside a code cell, or asubprocessthat shells out, never fires an intent and is not touched by these rules. What bounds those is the rung below:code-runtime-pythonconfines the kernel itself. The row says so at mount and carries the sentence onctx.fs_permissions.reach, so the statement toggles with the sandbox rather than being a paragraph in a README that is wrong half the time.- Offloading never deletes. Both directions are a surface
replace: the log keeps every event,derive_messages()yields the preview, andtranscript()still shows the person what they sent. Rewriting a message before logging it was the alternative and is refused — the log would then attribute harness-authored text to the human. - Offloading fails open. If the spill write fails, the original result is kept: an offload that cannot store the content must not be the reason the model loses it.
- Self-limiting tools are left alone, and they say so themselves. A tool
that bounds its own output and offers a way to page (
readwith an offset,grepwith a match cap) setsToolDefinition.self_limits. Upstream matches a hardcoded list of its tool names; pH asks the tool, because a deployment renames them and an MCP server adds its own.excludedToolsis the escape hatch for a third-party tool whose author has not declared. - Compaction can decline. The cut is moved back to the nearest balanced
boundary so a call is never split from its result; if the only balanced cut is
the start of the conversation, nothing is compacted and the attempt says
so. Shipping an orphaned
tool-resultto a provider that rejects it is not a repair. Two cheaper remedies run first — eliding over-long call arguments in retained history, then spilling the trailing tool-result batch. - Limits are a fold over the log, not a counter in memory. A limit that
lives in a field is a limit a resume forgets. Upstream's thread and run
map to pH's session and turn, renamed once here so a diff against
model_call_limit.pystays readable. skill-stepsholds the model to its own accepted plan, not to reality. Marking a step done is still the model's word; it asserts only that work remains which can begin, and never claims to know which step is running. Gates that check the world arectx.goalsandctx.approval. It is also useless withouttool-todo— the list it seeds is that row's — so a profile enabling one enables both.
Tests
tests/ — nine modules, one per feature plus the classifier. The destructive-command
tables are a starting point and are meant to grow: neither list claims to be
complete, and the honest posture is that the gate reports what it found, in the
parser's own words, so a person is told what the harness saw and an auditor can
tune it.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file ph_stabilize-0.1.0.tar.gz.
File metadata
- Download URL: ph_stabilize-0.1.0.tar.gz
- Upload date:
- Size: 143.4 kB
- Tags: Source
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
207e66c71c2356557ec2dc3a8cc80179d7dcc186a2270697cf4db6bbd81e362b
|
|
| MD5 |
0c193f1fc80f73daf641a4f9537fe6b9
|
|
| BLAKE2b-256 |
04cea65e60af26cc64920525470366f39dc7d87848c46b03c2c05b23a85bf7b8
|