ph-stabilize 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ph_stabilize-0.1.0/.gitignore +28 -0
- ph_stabilize-0.1.0/LICENSE +21 -0
- ph_stabilize-0.1.0/PKG-INFO +248 -0
- ph_stabilize-0.1.0/README.md +222 -0
- ph_stabilize-0.1.0/pyproject.toml +53 -0
- ph_stabilize-0.1.0/src/ph_stabilize/__init__.py +17 -0
- ph_stabilize-0.1.0/src/ph_stabilize/bundle.yaml +110 -0
- ph_stabilize-0.1.0/src/ph_stabilize/compact_command.py +86 -0
- ph_stabilize-0.1.0/src/ph_stabilize/compaction.py +1344 -0
- ph_stabilize-0.1.0/src/ph_stabilize/destructive.py +517 -0
- ph_stabilize-0.1.0/src/ph_stabilize/hitl.py +266 -0
- ph_stabilize-0.1.0/src/ph_stabilize/input_offload.py +165 -0
- ph_stabilize-0.1.0/src/ph_stabilize/limits.py +619 -0
- ph_stabilize-0.1.0/src/ph_stabilize/offload.py +268 -0
- ph_stabilize-0.1.0/src/ph_stabilize/permissions_fs.py +701 -0
- ph_stabilize-0.1.0/src/ph_stabilize/py.typed +0 -0
- ph_stabilize-0.1.0/src/ph_stabilize/skill_steps.py +262 -0
- ph_stabilize-0.1.0/src/ph_stabilize/todo.py +778 -0
- ph_stabilize-0.1.0/tests/stabilize_helpers.py +205 -0
- ph_stabilize-0.1.0/tests/test_compaction.py +1360 -0
- ph_stabilize-0.1.0/tests/test_destructive.py +225 -0
- ph_stabilize-0.1.0/tests/test_hitl.py +424 -0
- ph_stabilize-0.1.0/tests/test_input_offload.py +219 -0
- ph_stabilize-0.1.0/tests/test_limits.py +495 -0
- ph_stabilize-0.1.0/tests/test_offload.py +394 -0
- ph_stabilize-0.1.0/tests/test_permissions_fs.py +879 -0
- ph_stabilize-0.1.0/tests/test_skill_steps.py +466 -0
- ph_stabilize-0.1.0/tests/test_todo.py +562 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build and environment
|
|
2
|
+
.venv/
|
|
3
|
+
dist/
|
|
4
|
+
build/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
jjt/
|
|
9
|
+
w2/
|
|
10
|
+
|
|
11
|
+
# Tooling caches
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
# Dropped at the repo root by pytest-textual-snapshot when a snapshot test
|
|
18
|
+
# fails. The reference snapshots under `__snapshots__/` are the committed
|
|
19
|
+
# expectation; this is the diff viewer for a run that did not match one.
|
|
20
|
+
snapshot_report.html
|
|
21
|
+
|
|
22
|
+
# Reference checkouts of the upstream projects this port reads from. Vendored
|
|
23
|
+
# locally so the plans' citations are verifiable; never part of this repo.
|
|
24
|
+
sources/
|
|
25
|
+
|
|
26
|
+
# Local scratch
|
|
27
|
+
.ph/
|
|
28
|
+
*.local.yaml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Charles Tabor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ph-stabilize
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions.
|
|
5
|
+
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
|
+
Project-URL: Repository, https://github.com/chastabor/pH
|
|
7
|
+
Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
|
|
8
|
+
Project-URL: Issues, https://github.com/chastabor/pH/issues
|
|
9
|
+
Author: Charles Tabor
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,compaction,hitl,llm,permissions,todo
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: ph-core==0.1.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# ph-stabilize
|
|
28
|
+
|
|
29
|
+
*The things that keep a long session from falling over: a plan, offloading in
|
|
30
|
+
both directions, compaction, limits, a human in the loop, and path rules — each
|
|
31
|
+
a row on a seam that already exists.*
|
|
32
|
+
|
|
33
|
+
Deep Agents' stabilization features are *algorithms plus prompts, not runtime*,
|
|
34
|
+
so none of them lands as a parameter on the agent loop. Todo planning is a tool
|
|
35
|
+
plus a prompt section. Offloading is a `tools/post-execute` listener.
|
|
36
|
+
Summarization is `agent/pre-step`. The driver does not know any of them is
|
|
37
|
+
there — which is the whole integration thesis (D12), and the reason a deployment
|
|
38
|
+
that wants the plain harness gets the plain harness.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
phern --profile rlm-stable --provider llama --model <model> --mode tui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The package registers the `stabilize` **bundle** (`src/ph_stabilize/bundle.yaml`),
|
|
45
|
+
which `ph-app` composes through the `ph.bundles` entry-point group without
|
|
46
|
+
depending on this distribution.
|
|
47
|
+
|
|
48
|
+
**Every profile `ph-app` offers layers it** — `base` included — as an *optional*
|
|
49
|
+
bundle: an install without this distribution composes the same profiles and
|
|
50
|
+
simply never compacts. Compaction is the row that earns that, because a session
|
|
51
|
+
that grows until the provider refuses it is a defect in any posture; the rest
|
|
52
|
+
come along because a bundle is the unit a profile can name, and they are inert
|
|
53
|
+
until configured. `phern doctor` reports which of them actually activated.
|
|
54
|
+
|
|
55
|
+
## The rows
|
|
56
|
+
|
|
57
|
+
| row | what it does | shipped |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `tool-todo` | `write_todos`, the `todo/write` event, the prompt section, and the one-call-per-turn rule | **off** |
|
|
60
|
+
| `skill-steps` | a `SKILL.md` that declares `steps:` becomes work the turn must finish | **off** |
|
|
61
|
+
| `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 |
|
|
62
|
+
| `input-offload` | the other direction: a pasted build log or dumped table | on |
|
|
63
|
+
| `compaction-summarize` | the conversation replaced by a summary when it stops fitting | on |
|
|
64
|
+
| `command-compact` | `/compact`, the human verb for the same thing | on |
|
|
65
|
+
| `limits` | model-call, tool-call and child ceilings, plus a consecutive-failure breaker | on, every ceiling unset |
|
|
66
|
+
| `hitl` | a person between the model and what it cannot take back | on, asking about nothing |
|
|
67
|
+
| `permissions-fs` | path rules over `ctx.fs` | on, with one default write rule |
|
|
68
|
+
|
|
69
|
+
**"On" mostly means "inert."** Layering this bundle must not, by itself, change
|
|
70
|
+
what a deployment does: `limits` mounts with every ceiling unset, `hitl` with an
|
|
71
|
+
empty `interruptOn`, `permissions-fs` with rules that allow everything inside
|
|
72
|
+
the workspace. A harness that starts asking on first run teaches its user to
|
|
73
|
+
approve without reading, and a limit nobody chose is a limit that fires on
|
|
74
|
+
somebody's longest legitimate turn.
|
|
75
|
+
|
|
76
|
+
The two that ship **off** are off for a different reason: they hand the model a
|
|
77
|
+
tool or keep a turn going, and that is a posture a profile chooses rather than
|
|
78
|
+
inherits. `rlm-stable` is the profile that chooses it — and the reason the
|
|
79
|
+
bundle reaching every profile does not thereby give every deployment a todo
|
|
80
|
+
tool.
|
|
81
|
+
|
|
82
|
+
The one row that is **not** inert on arrival is `permissions-fs`: it ships a
|
|
83
|
+
rule sending writes outside the agent's workspace to `interrupt`. That is E6's
|
|
84
|
+
intended default rather than an oversight, but it is a behaviour change a
|
|
85
|
+
deployment should know it inherited — `phern config --row permissions-fs` prints
|
|
86
|
+
what is in force.
|
|
87
|
+
|
|
88
|
+
## The tool, and the command
|
|
89
|
+
|
|
90
|
+
- **`write_todos`** (row `tool-todo`) — the whole list, replaced. The list lives
|
|
91
|
+
in the log and nowhere else, so the TUI sidebar and the model's own view are
|
|
92
|
+
one projection rather than two that can disagree, and it survives a resume and
|
|
93
|
+
a fork for free. Two deliberate forks from upstream: `requires` declares what
|
|
94
|
+
a step waits on (upstream works around the gap in *prose*, which is a
|
|
95
|
+
dependency graph nothing can read or check), and a completed entry carries
|
|
96
|
+
`worked` — the number of tools the harness saw run while it was being
|
|
97
|
+
finished — which is the one field the model does not write, because a receipt
|
|
98
|
+
the claimant issues is not a receipt.
|
|
99
|
+
- **`/compact`** (row `command-compact`) — replace older history with a summary,
|
|
100
|
+
optionally told what you are about to work on. A command, not a turn: it
|
|
101
|
+
dispatches directly, records `command/run`/`command/done`, and insists the
|
|
102
|
+
agent is idle first, because a compaction landing mid-turn would move the
|
|
103
|
+
surface underneath a request the loop had already derived.
|
|
104
|
+
|
|
105
|
+
Every failure is a sentence rather than a traceback — `CompactionError.code` is
|
|
106
|
+
a closed set precisely so a front end can phrase each one.
|
|
107
|
+
|
|
108
|
+
## Including it, and adjusting it
|
|
109
|
+
|
|
110
|
+
`rlm-stable` is `rlm` plus this bundle plus the rows it arms. Without a profile
|
|
111
|
+
that names the bundle, insert the rows you want by name — they are ordinary
|
|
112
|
+
plugin entry points, so this works against any profile:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
phern --patch '{insert: [{id: tool-result-offload, name: tool-result-offload}]}' \
|
|
116
|
+
--profile llama -p "…"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
# $PH_HOME/profiles/rlm-stable.yaml — ceilings, and a narrower file rule
|
|
121
|
+
- id: limits
|
|
122
|
+
config:
|
|
123
|
+
modelCalls: {turnLimit: 40, sessionLimit: 400, exit: end}
|
|
124
|
+
toolCalls: {turnLimit: 100, perTool: {bash: {turnLimit: 20}}, exit: continue}
|
|
125
|
+
breaker: {consecutiveFailures: 5}
|
|
126
|
+
|
|
127
|
+
- id: permissions-fs
|
|
128
|
+
config:
|
|
129
|
+
rules:
|
|
130
|
+
- operations: [read]
|
|
131
|
+
paths: ["**/.env", "**/*.pem"]
|
|
132
|
+
mode: deny
|
|
133
|
+
- operations: [write]
|
|
134
|
+
paths: ["**"]
|
|
135
|
+
scope: outside-workspace
|
|
136
|
+
mode: interrupt
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
| row | config | default |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `tool-result-offload` | `tokenLimit`, `maxInlineBytes`, `excludedTools` | `20000`; `None` disables offloading |
|
|
142
|
+
| `input-offload` | `tokenLimit` | `50000` |
|
|
143
|
+
| `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 |
|
|
144
|
+
| `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` |
|
|
145
|
+
| `hitl` | `mode` (`manual` \| `auto` \| `yolo`), `declared`, `interruptOn` | `auto`, nothing configured |
|
|
146
|
+
| `permissions-fs` | `rules` — each `operations`, `paths`, `scope` (`anywhere` \| `outside-workspace`), `mode` (default `deny`), `description` | one rule: `write` anywhere `outside-workspace` → `interrupt` |
|
|
147
|
+
| `tool-todo`, `skill-steps`, `command-compact` | — | no configuration |
|
|
148
|
+
|
|
149
|
+
### Writing a `hitl` rule
|
|
150
|
+
|
|
151
|
+
Keys are tool names, and each takes a `preset`, a `when:` list of regexes, a
|
|
152
|
+
`description` and an allowed decision set. `rlm-stable`'s own rules are the
|
|
153
|
+
worked example:
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
- id: hitl
|
|
157
|
+
config:
|
|
158
|
+
interruptOn:
|
|
159
|
+
bash:
|
|
160
|
+
preset: destructive
|
|
161
|
+
when: ["\\b(npm|pnpm|yarn|uv|pip)\\s+publish\\b", "\\bsudo\\b"]
|
|
162
|
+
description: >-
|
|
163
|
+
This command changes something a worktree cannot undo.
|
|
164
|
+
run_code:
|
|
165
|
+
preset: destructive
|
|
166
|
+
when: ["\\bsubprocess\\b"]
|
|
167
|
+
allowedDecisions: [approve, reject]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Three things that rule says, spelled out:
|
|
171
|
+
|
|
172
|
+
- **Name the preset rather than retyping it.** `destructive` is the set this
|
|
173
|
+
package ships and tests. The first draft of `rlm-stable.yaml` retyped a subset
|
|
174
|
+
and had already widened `git push` from force-only to *every* push, against a
|
|
175
|
+
shipped test pinning an ordinary push as ordinary — one security judgement
|
|
176
|
+
beats two that disagree.
|
|
177
|
+
- **Key Code Mode on the *reserved* name.** The registry renames the transport
|
|
178
|
+
to whatever the presentation calls it (`ipython`, in the `rlm` bundle) and
|
|
179
|
+
`hitl` resolves that. Keying on the presented name would not turn the gate off
|
|
180
|
+
— the tool declares `is_irreversible`, so `declared` still catches it — but
|
|
181
|
+
the `when:` additions are keyed by name and would silently stop applying.
|
|
182
|
+
- **Four decisions, not two.** `approve` and `reject` were always reachable;
|
|
183
|
+
this row adds `edit` (run it with these arguments instead) and `respond` (do
|
|
184
|
+
not run it — tell the model this), because stopping a turn to say "wrong path"
|
|
185
|
+
or "you don't need that, the answer is X" costs a round trip that answering in
|
|
186
|
+
place does not. `allowedDecisions` narrows the set — a program is not
|
|
187
|
+
something to hand-patch in a modal, so `run_code` above drops `edit`.
|
|
188
|
+
|
|
189
|
+
**The classifier parses; it does not pattern-match.** `preset: destructive` runs
|
|
190
|
+
`ph_stabilize.destructive`, which reads each string argument in its own dialect
|
|
191
|
+
— shell through `shlex`, SQL as statements, Python through `ast` — and judges
|
|
192
|
+
the *structure*. The twelve regexes it replaced were run over the call's
|
|
193
|
+
arguments rendered as JSON, so a real newline became the two characters `\` and
|
|
194
|
+
`n` and every `\b`-anchored pattern stopped matching on a second line: `rm -rf`,
|
|
195
|
+
`DROP TABLE`, `shutil.rmtree` and `curl | sh` were all ungated inside a
|
|
196
|
+
multi-line cell, which is *every* `run_code` cell. Nothing failed; the gate
|
|
197
|
+
simply did not fire. A `when:` list is still regex, as the deployment's own
|
|
198
|
+
escape hatch, but it is matched against the decoded strings.
|
|
199
|
+
|
|
200
|
+
## Limitations, and things that are deliberate
|
|
201
|
+
|
|
202
|
+
- **`permissions-fs` bounds seam-mediated access only** (E9, N1). The rules
|
|
203
|
+
attach to `ctx.fs`'s intent waterfalls, so any tool going through the seam is
|
|
204
|
+
covered without this module knowing a tool name — and a model-authored
|
|
205
|
+
`open(path, "w")` inside a code cell, or a `subprocess` that shells out, never
|
|
206
|
+
fires an intent and is not touched by *these rules*. What bounds those is the
|
|
207
|
+
rung below: `code-runtime-python` confines the kernel itself. The row says so
|
|
208
|
+
at mount and carries the sentence on `ctx.fs_permissions.reach`, so the
|
|
209
|
+
statement toggles with the sandbox rather than being a paragraph in a README
|
|
210
|
+
that is wrong half the time.
|
|
211
|
+
- **Offloading never deletes.** Both directions are a surface `replace`: the log
|
|
212
|
+
keeps every event, `derive_messages()` yields the preview, and `transcript()`
|
|
213
|
+
still shows the person what they sent. Rewriting a message *before* logging it
|
|
214
|
+
was the alternative and is refused — the log would then attribute
|
|
215
|
+
harness-authored text to the human.
|
|
216
|
+
- **Offloading fails open.** If the spill write fails, the original result is
|
|
217
|
+
kept: an offload that cannot store the content must not be the reason the
|
|
218
|
+
model loses it.
|
|
219
|
+
- **Self-limiting tools are left alone, and they say so themselves.** A tool
|
|
220
|
+
that bounds its own output and offers a way to page (`read` with an offset,
|
|
221
|
+
`grep` with a match cap) sets `ToolDefinition.self_limits`. Upstream matches a
|
|
222
|
+
hardcoded list of *its* tool names; pH asks the tool, because a deployment
|
|
223
|
+
renames them and an MCP server adds its own. `excludedTools` is the escape
|
|
224
|
+
hatch for a third-party tool whose author has not declared.
|
|
225
|
+
- **Compaction can decline.** The cut is moved back to the nearest balanced
|
|
226
|
+
boundary so a call is never split from its result; if the only balanced cut is
|
|
227
|
+
the start of the conversation, nothing is compacted and the attempt **says
|
|
228
|
+
so**. Shipping an orphaned `tool-result` to a provider that rejects it is not
|
|
229
|
+
a repair. Two cheaper remedies run first — eliding over-long call arguments in
|
|
230
|
+
retained history, then spilling the trailing tool-result batch.
|
|
231
|
+
- **Limits are a fold over the log, not a counter in memory.** A limit that
|
|
232
|
+
lives in a field is a limit a resume forgets. Upstream's *thread* and *run*
|
|
233
|
+
map to pH's **session** and **turn**, renamed once here so a diff against
|
|
234
|
+
`model_call_limit.py` stays readable.
|
|
235
|
+
- **`skill-steps` holds the model to its own accepted plan, not to reality.**
|
|
236
|
+
Marking a step done is still the model's word; it asserts only that work
|
|
237
|
+
remains which *can* begin, and never claims to know which step is running.
|
|
238
|
+
Gates that check the world are `ctx.goals` and `ctx.approval`. It is also
|
|
239
|
+
useless without `tool-todo` — the list it seeds is that row's — so a profile
|
|
240
|
+
enabling one enables both.
|
|
241
|
+
|
|
242
|
+
## Tests
|
|
243
|
+
|
|
244
|
+
`tests/` — nine modules, one per feature plus the classifier. The destructive-command
|
|
245
|
+
tables are a starting point and are meant to grow: neither list claims to be
|
|
246
|
+
complete, and the honest posture is that the gate reports what it found, in the
|
|
247
|
+
parser's own words, so a person is told what the harness saw and an auditor can
|
|
248
|
+
tune it.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# ph-stabilize
|
|
2
|
+
|
|
3
|
+
*The things that keep a long session from falling over: a plan, offloading in
|
|
4
|
+
both directions, compaction, limits, a human in the loop, and path rules — each
|
|
5
|
+
a row on a seam that already exists.*
|
|
6
|
+
|
|
7
|
+
Deep Agents' stabilization features are *algorithms plus prompts, not runtime*,
|
|
8
|
+
so none of them lands as a parameter on the agent loop. Todo planning is a tool
|
|
9
|
+
plus a prompt section. Offloading is a `tools/post-execute` listener.
|
|
10
|
+
Summarization is `agent/pre-step`. The driver does not know any of them is
|
|
11
|
+
there — which is the whole integration thesis (D12), and the reason a deployment
|
|
12
|
+
that wants the plain harness gets the plain harness.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
phern --profile rlm-stable --provider llama --model <model> --mode tui
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The package registers the `stabilize` **bundle** (`src/ph_stabilize/bundle.yaml`),
|
|
19
|
+
which `ph-app` composes through the `ph.bundles` entry-point group without
|
|
20
|
+
depending on this distribution.
|
|
21
|
+
|
|
22
|
+
**Every profile `ph-app` offers layers it** — `base` included — as an *optional*
|
|
23
|
+
bundle: an install without this distribution composes the same profiles and
|
|
24
|
+
simply never compacts. Compaction is the row that earns that, because a session
|
|
25
|
+
that grows until the provider refuses it is a defect in any posture; the rest
|
|
26
|
+
come along because a bundle is the unit a profile can name, and they are inert
|
|
27
|
+
until configured. `phern doctor` reports which of them actually activated.
|
|
28
|
+
|
|
29
|
+
## The rows
|
|
30
|
+
|
|
31
|
+
| row | what it does | shipped |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `tool-todo` | `write_todos`, the `todo/write` event, the prompt section, and the one-call-per-turn rule | **off** |
|
|
34
|
+
| `skill-steps` | a `SKILL.md` that declares `steps:` becomes work the turn must finish | **off** |
|
|
35
|
+
| `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 |
|
|
36
|
+
| `input-offload` | the other direction: a pasted build log or dumped table | on |
|
|
37
|
+
| `compaction-summarize` | the conversation replaced by a summary when it stops fitting | on |
|
|
38
|
+
| `command-compact` | `/compact`, the human verb for the same thing | on |
|
|
39
|
+
| `limits` | model-call, tool-call and child ceilings, plus a consecutive-failure breaker | on, every ceiling unset |
|
|
40
|
+
| `hitl` | a person between the model and what it cannot take back | on, asking about nothing |
|
|
41
|
+
| `permissions-fs` | path rules over `ctx.fs` | on, with one default write rule |
|
|
42
|
+
|
|
43
|
+
**"On" mostly means "inert."** Layering this bundle must not, by itself, change
|
|
44
|
+
what a deployment does: `limits` mounts with every ceiling unset, `hitl` with an
|
|
45
|
+
empty `interruptOn`, `permissions-fs` with rules that allow everything inside
|
|
46
|
+
the workspace. A harness that starts asking on first run teaches its user to
|
|
47
|
+
approve without reading, and a limit nobody chose is a limit that fires on
|
|
48
|
+
somebody's longest legitimate turn.
|
|
49
|
+
|
|
50
|
+
The two that ship **off** are off for a different reason: they hand the model a
|
|
51
|
+
tool or keep a turn going, and that is a posture a profile chooses rather than
|
|
52
|
+
inherits. `rlm-stable` is the profile that chooses it — and the reason the
|
|
53
|
+
bundle reaching every profile does not thereby give every deployment a todo
|
|
54
|
+
tool.
|
|
55
|
+
|
|
56
|
+
The one row that is **not** inert on arrival is `permissions-fs`: it ships a
|
|
57
|
+
rule sending writes outside the agent's workspace to `interrupt`. That is E6's
|
|
58
|
+
intended default rather than an oversight, but it is a behaviour change a
|
|
59
|
+
deployment should know it inherited — `phern config --row permissions-fs` prints
|
|
60
|
+
what is in force.
|
|
61
|
+
|
|
62
|
+
## The tool, and the command
|
|
63
|
+
|
|
64
|
+
- **`write_todos`** (row `tool-todo`) — the whole list, replaced. The list lives
|
|
65
|
+
in the log and nowhere else, so the TUI sidebar and the model's own view are
|
|
66
|
+
one projection rather than two that can disagree, and it survives a resume and
|
|
67
|
+
a fork for free. Two deliberate forks from upstream: `requires` declares what
|
|
68
|
+
a step waits on (upstream works around the gap in *prose*, which is a
|
|
69
|
+
dependency graph nothing can read or check), and a completed entry carries
|
|
70
|
+
`worked` — the number of tools the harness saw run while it was being
|
|
71
|
+
finished — which is the one field the model does not write, because a receipt
|
|
72
|
+
the claimant issues is not a receipt.
|
|
73
|
+
- **`/compact`** (row `command-compact`) — replace older history with a summary,
|
|
74
|
+
optionally told what you are about to work on. A command, not a turn: it
|
|
75
|
+
dispatches directly, records `command/run`/`command/done`, and insists the
|
|
76
|
+
agent is idle first, because a compaction landing mid-turn would move the
|
|
77
|
+
surface underneath a request the loop had already derived.
|
|
78
|
+
|
|
79
|
+
Every failure is a sentence rather than a traceback — `CompactionError.code` is
|
|
80
|
+
a closed set precisely so a front end can phrase each one.
|
|
81
|
+
|
|
82
|
+
## Including it, and adjusting it
|
|
83
|
+
|
|
84
|
+
`rlm-stable` is `rlm` plus this bundle plus the rows it arms. Without a profile
|
|
85
|
+
that names the bundle, insert the rows you want by name — they are ordinary
|
|
86
|
+
plugin entry points, so this works against any profile:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
phern --patch '{insert: [{id: tool-result-offload, name: tool-result-offload}]}' \
|
|
90
|
+
--profile llama -p "…"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
# $PH_HOME/profiles/rlm-stable.yaml — ceilings, and a narrower file rule
|
|
95
|
+
- id: limits
|
|
96
|
+
config:
|
|
97
|
+
modelCalls: {turnLimit: 40, sessionLimit: 400, exit: end}
|
|
98
|
+
toolCalls: {turnLimit: 100, perTool: {bash: {turnLimit: 20}}, exit: continue}
|
|
99
|
+
breaker: {consecutiveFailures: 5}
|
|
100
|
+
|
|
101
|
+
- id: permissions-fs
|
|
102
|
+
config:
|
|
103
|
+
rules:
|
|
104
|
+
- operations: [read]
|
|
105
|
+
paths: ["**/.env", "**/*.pem"]
|
|
106
|
+
mode: deny
|
|
107
|
+
- operations: [write]
|
|
108
|
+
paths: ["**"]
|
|
109
|
+
scope: outside-workspace
|
|
110
|
+
mode: interrupt
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| row | config | default |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `tool-result-offload` | `tokenLimit`, `maxInlineBytes`, `excludedTools` | `20000`; `None` disables offloading |
|
|
116
|
+
| `input-offload` | `tokenLimit` | `50000` |
|
|
117
|
+
| `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 |
|
|
118
|
+
| `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` |
|
|
119
|
+
| `hitl` | `mode` (`manual` \| `auto` \| `yolo`), `declared`, `interruptOn` | `auto`, nothing configured |
|
|
120
|
+
| `permissions-fs` | `rules` — each `operations`, `paths`, `scope` (`anywhere` \| `outside-workspace`), `mode` (default `deny`), `description` | one rule: `write` anywhere `outside-workspace` → `interrupt` |
|
|
121
|
+
| `tool-todo`, `skill-steps`, `command-compact` | — | no configuration |
|
|
122
|
+
|
|
123
|
+
### Writing a `hitl` rule
|
|
124
|
+
|
|
125
|
+
Keys are tool names, and each takes a `preset`, a `when:` list of regexes, a
|
|
126
|
+
`description` and an allowed decision set. `rlm-stable`'s own rules are the
|
|
127
|
+
worked example:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
- id: hitl
|
|
131
|
+
config:
|
|
132
|
+
interruptOn:
|
|
133
|
+
bash:
|
|
134
|
+
preset: destructive
|
|
135
|
+
when: ["\\b(npm|pnpm|yarn|uv|pip)\\s+publish\\b", "\\bsudo\\b"]
|
|
136
|
+
description: >-
|
|
137
|
+
This command changes something a worktree cannot undo.
|
|
138
|
+
run_code:
|
|
139
|
+
preset: destructive
|
|
140
|
+
when: ["\\bsubprocess\\b"]
|
|
141
|
+
allowedDecisions: [approve, reject]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Three things that rule says, spelled out:
|
|
145
|
+
|
|
146
|
+
- **Name the preset rather than retyping it.** `destructive` is the set this
|
|
147
|
+
package ships and tests. The first draft of `rlm-stable.yaml` retyped a subset
|
|
148
|
+
and had already widened `git push` from force-only to *every* push, against a
|
|
149
|
+
shipped test pinning an ordinary push as ordinary — one security judgement
|
|
150
|
+
beats two that disagree.
|
|
151
|
+
- **Key Code Mode on the *reserved* name.** The registry renames the transport
|
|
152
|
+
to whatever the presentation calls it (`ipython`, in the `rlm` bundle) and
|
|
153
|
+
`hitl` resolves that. Keying on the presented name would not turn the gate off
|
|
154
|
+
— the tool declares `is_irreversible`, so `declared` still catches it — but
|
|
155
|
+
the `when:` additions are keyed by name and would silently stop applying.
|
|
156
|
+
- **Four decisions, not two.** `approve` and `reject` were always reachable;
|
|
157
|
+
this row adds `edit` (run it with these arguments instead) and `respond` (do
|
|
158
|
+
not run it — tell the model this), because stopping a turn to say "wrong path"
|
|
159
|
+
or "you don't need that, the answer is X" costs a round trip that answering in
|
|
160
|
+
place does not. `allowedDecisions` narrows the set — a program is not
|
|
161
|
+
something to hand-patch in a modal, so `run_code` above drops `edit`.
|
|
162
|
+
|
|
163
|
+
**The classifier parses; it does not pattern-match.** `preset: destructive` runs
|
|
164
|
+
`ph_stabilize.destructive`, which reads each string argument in its own dialect
|
|
165
|
+
— shell through `shlex`, SQL as statements, Python through `ast` — and judges
|
|
166
|
+
the *structure*. The twelve regexes it replaced were run over the call's
|
|
167
|
+
arguments rendered as JSON, so a real newline became the two characters `\` and
|
|
168
|
+
`n` and every `\b`-anchored pattern stopped matching on a second line: `rm -rf`,
|
|
169
|
+
`DROP TABLE`, `shutil.rmtree` and `curl | sh` were all ungated inside a
|
|
170
|
+
multi-line cell, which is *every* `run_code` cell. Nothing failed; the gate
|
|
171
|
+
simply did not fire. A `when:` list is still regex, as the deployment's own
|
|
172
|
+
escape hatch, but it is matched against the decoded strings.
|
|
173
|
+
|
|
174
|
+
## Limitations, and things that are deliberate
|
|
175
|
+
|
|
176
|
+
- **`permissions-fs` bounds seam-mediated access only** (E9, N1). The rules
|
|
177
|
+
attach to `ctx.fs`'s intent waterfalls, so any tool going through the seam is
|
|
178
|
+
covered without this module knowing a tool name — and a model-authored
|
|
179
|
+
`open(path, "w")` inside a code cell, or a `subprocess` that shells out, never
|
|
180
|
+
fires an intent and is not touched by *these rules*. What bounds those is the
|
|
181
|
+
rung below: `code-runtime-python` confines the kernel itself. The row says so
|
|
182
|
+
at mount and carries the sentence on `ctx.fs_permissions.reach`, so the
|
|
183
|
+
statement toggles with the sandbox rather than being a paragraph in a README
|
|
184
|
+
that is wrong half the time.
|
|
185
|
+
- **Offloading never deletes.** Both directions are a surface `replace`: the log
|
|
186
|
+
keeps every event, `derive_messages()` yields the preview, and `transcript()`
|
|
187
|
+
still shows the person what they sent. Rewriting a message *before* logging it
|
|
188
|
+
was the alternative and is refused — the log would then attribute
|
|
189
|
+
harness-authored text to the human.
|
|
190
|
+
- **Offloading fails open.** If the spill write fails, the original result is
|
|
191
|
+
kept: an offload that cannot store the content must not be the reason the
|
|
192
|
+
model loses it.
|
|
193
|
+
- **Self-limiting tools are left alone, and they say so themselves.** A tool
|
|
194
|
+
that bounds its own output and offers a way to page (`read` with an offset,
|
|
195
|
+
`grep` with a match cap) sets `ToolDefinition.self_limits`. Upstream matches a
|
|
196
|
+
hardcoded list of *its* tool names; pH asks the tool, because a deployment
|
|
197
|
+
renames them and an MCP server adds its own. `excludedTools` is the escape
|
|
198
|
+
hatch for a third-party tool whose author has not declared.
|
|
199
|
+
- **Compaction can decline.** The cut is moved back to the nearest balanced
|
|
200
|
+
boundary so a call is never split from its result; if the only balanced cut is
|
|
201
|
+
the start of the conversation, nothing is compacted and the attempt **says
|
|
202
|
+
so**. Shipping an orphaned `tool-result` to a provider that rejects it is not
|
|
203
|
+
a repair. Two cheaper remedies run first — eliding over-long call arguments in
|
|
204
|
+
retained history, then spilling the trailing tool-result batch.
|
|
205
|
+
- **Limits are a fold over the log, not a counter in memory.** A limit that
|
|
206
|
+
lives in a field is a limit a resume forgets. Upstream's *thread* and *run*
|
|
207
|
+
map to pH's **session** and **turn**, renamed once here so a diff against
|
|
208
|
+
`model_call_limit.py` stays readable.
|
|
209
|
+
- **`skill-steps` holds the model to its own accepted plan, not to reality.**
|
|
210
|
+
Marking a step done is still the model's word; it asserts only that work
|
|
211
|
+
remains which *can* begin, and never claims to know which step is running.
|
|
212
|
+
Gates that check the world are `ctx.goals` and `ctx.approval`. It is also
|
|
213
|
+
useless without `tool-todo` — the list it seeds is that row's — so a profile
|
|
214
|
+
enabling one enables both.
|
|
215
|
+
|
|
216
|
+
## Tests
|
|
217
|
+
|
|
218
|
+
`tests/` — nine modules, one per feature plus the classifier. The destructive-command
|
|
219
|
+
tables are a starting point and are meant to grow: neither list claims to be
|
|
220
|
+
complete, and the honest posture is that the gate reports what it found, in the
|
|
221
|
+
parser's own words, so a person is told what the harness saw and an auditor can
|
|
222
|
+
tune it.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ph-stabilize"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Charles Tabor" }]
|
|
14
|
+
keywords = ["agent", "llm", "compaction", "todo", "hitl", "permissions"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Topic :: Software Development",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["ph-core==0.1.0"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/chastabor/pH"
|
|
31
|
+
Repository = "https://github.com/chastabor/pH"
|
|
32
|
+
Documentation = "https://github.com/chastabor/pH/blob/main/docs/README.md"
|
|
33
|
+
Issues = "https://github.com/chastabor/pH/issues"
|
|
34
|
+
|
|
35
|
+
[project.entry-points."ph.bundles"]
|
|
36
|
+
stabilize = "ph_stabilize:BUNDLE"
|
|
37
|
+
|
|
38
|
+
[project.entry-points."ph.plugins"]
|
|
39
|
+
command-compact = "ph_stabilize.compact_command:apply"
|
|
40
|
+
compaction-summarize = "ph_stabilize.compaction:apply"
|
|
41
|
+
hitl = "ph_stabilize.hitl:apply"
|
|
42
|
+
input-offload = "ph_stabilize.input_offload:apply"
|
|
43
|
+
limits = "ph_stabilize.limits:apply"
|
|
44
|
+
permissions-fs = "ph_stabilize.permissions_fs:apply"
|
|
45
|
+
tool-result-offload = "ph_stabilize.offload:apply"
|
|
46
|
+
tool-todo = "ph_stabilize.todo:apply"
|
|
47
|
+
skill-steps = "ph_stabilize.skill_steps:apply"
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
ph-core = { workspace = true }
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/ph_stabilize"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions.
|
|
2
|
+
|
|
3
|
+
Deep Agents' features are *algorithms plus prompts, not runtime* (§1.3 of the
|
|
4
|
+
port plan), so each one lands here as a row on a seam `ph-core` already
|
|
5
|
+
publishes. Reserved in Phase 0 (P0-01); built from Phase 4.
|
|
6
|
+
|
|
7
|
+
@module ph_stabilize
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
BUNDLE = Path(__file__).parent / "bundle.yaml"
|
|
15
|
+
"""The rows the `stabilize` layer adds over `ph-base`."""
|
|
16
|
+
|
|
17
|
+
__all__ = ["BUNDLE"]
|