ph-rlm 0.1.0__py3-none-any.whl

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_rlm/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """pH's RLM bundle: Prime Agent's design implemented on pH's seams.
2
+
3
+ The rule the whole package follows (§6.8): take prime-agent's *semantics* — the
4
+ RLM loop, non-blocking admission, the nuclear-family boundary, the Continual
5
+ Harness, the doctrine prompts — and implement them on pH's seams. Do not take its
6
+ runtime. `ph_rlm.kernel` is pH's own, and `ph_runtime` is its guest half.
7
+
8
+ @module ph_rlm
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ BUNDLE = Path(__file__).parent / "bundle.yaml"
16
+ """The rows the `rlm` profile layers over `ph-base`."""
17
+
18
+ __all__ = ["BUNDLE"]
ph_rlm/bindings.py ADDED
@@ -0,0 +1,259 @@
1
+ """`rlm-bindings` — the `rlm` namespace, as governed tool calls (P3-10, C2/C3).
2
+
3
+ Prime Agent's `await rlm("task", name=..., model=...)` travelled over a Jupyter
4
+ comm channel as a `host.request`, which meant it was invisible to
5
+ `tools/pre-execute`, to `ctx.approval`, to the call limits and to the offload
6
+ policy. Here the same call is a **binding**: one `call` frame out of the kernel,
7
+ through the whole tool pipeline, landing as a durable
8
+ `tool/code-dispatch-start`/`tool/code-dispatch` pair. A deployment can therefore
9
+ deny or `ask` on subagent spawning by policy, and `max_subagent_spawns_per_run`
10
+ counts it, without a line of code here.
11
+
12
+ Two decisions worth stating:
13
+
14
+ **The program-facing name is not the tool name.** A cell writes `rlm.run(...)`;
15
+ the governed tool is `rlm_run`. A namespace cannot claim a bare global name like
16
+ `run` — and it does not need to, because the SDK renders the namespaced form
17
+ while the log records the tool. Only the tools exist as a registry surface, so a
18
+ policy row addresses `rlm_run` and means exactly one thing.
19
+
20
+ **Spawning is `counts_as_spawn`.** The bridge holds it to C4's spawn budget
21
+ rather than the general dispatch budget, so one approved cell cannot fan out
22
+ past `max_subagent_spawns_per_run` on the strength of that one approval.
23
+
24
+ `find_models` is deliberately absent: it would need a model *catalogue* on
25
+ `ctx.llm`, which does not exist yet. Advertising a discovery call that could only
26
+ answer "I don't know" would be worse than not offering one, and the model already
27
+ inherits the parent's model when it names none.
28
+
29
+ @module ph_rlm.bindings
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any
35
+
36
+ from ph.cordis import Context, plugin
37
+ from ph.json import JsonObject, as_str
38
+ from ph.keys import SUBAGENTS, TOOLS
39
+ from ph.llm.types import ContentBlock
40
+ from ph.seams.code_runtime import CodeBindingNamespace
41
+ from ph.seams.subagents import (
42
+ Access,
43
+ DowngradeReason,
44
+ SubagentRequest,
45
+ SubagentSpawnError,
46
+ downgrade_text,
47
+ )
48
+ from ph.tools import ToolModel, ToolOutput, ToolRunContext, define_tool, text_content
49
+ from ph.tools.code_mode import CodeBindingsRequest, ToolCallError, governed_binding
50
+ from ph.wire import WireModel
51
+
52
+ from .keys import RLM_CHILDREN
53
+ from .subagents import PROVIDER_NAME
54
+
55
+ __all__ = ["NAMESPACE", "Config", "apply"]
56
+
57
+ NAMESPACE = "rlm"
58
+
59
+ RUN_TOOL = "rlm_run"
60
+ LIST_TOOL = "rlm_list_subagents"
61
+ DELETE_TOOL = "rlm_delete_subagent"
62
+
63
+ _RUN_DESCRIPTION = (
64
+ "Delegate a task to a child agent. Returns immediately with an admission "
65
+ "handle — never the answer. The child replies by sending you an agent "
66
+ "message, which arrives on a later turn; do not wait or sleep for it."
67
+ )
68
+
69
+
70
+ class RunArgs(ToolModel):
71
+ """`rlm.run(...)`. Snake_case because the model types these names."""
72
+
73
+ prompt: str
74
+ name: str | None = None
75
+ model: str | None = None
76
+ thinking: str | None = None
77
+ access: Access = "read"
78
+ """`read` | `write`. Defaults to `read` (E4): a child that only needs to read
79
+ must not be handed a writable repo because nobody said otherwise. Typed, so
80
+ pydantic validates it and the model sees the two values in the schema."""
81
+ preset: str | None = None
82
+ """A named kind of child this deployment configured. Fills in what you did not
83
+ name; it cannot give the child more than you have (P4-13b)."""
84
+ skills: tuple[str, ...] | None = None
85
+ """Skills the child gets, by name from your own catalog. Naming one is also an
86
+ instruction: its full text goes in the child's prompt, so the child starts by
87
+ following that procedure rather than having to fetch it. `None` gives it
88
+ everything you have; you cannot name a skill you do not have (P4-13b)."""
89
+ tools: tuple[str, ...] | None = None
90
+ """Tools the child may call. `None` gives it everything you have. You cannot
91
+ name a tool you do not have."""
92
+
93
+
94
+ class DeleteArgs(ToolModel):
95
+ child_id: str
96
+ reason: str = "user"
97
+
98
+
99
+ class SpawnHandle(WireModel):
100
+ """What a spawn hands back. Deliberately not the answer."""
101
+
102
+ child_id: str
103
+ name: str
104
+ session_id: str
105
+ model: str
106
+ requested_access: Access
107
+ granted_access: Access
108
+ note: str | None = None
109
+ """The sentence for `SubagentRun.downgrade_reason`, rendered by the seam.
110
+
111
+ The model needs prose; the *log* keeps the code. One generator, so the two
112
+ cannot disagree and neither goes stale when the workspace tier lands."""
113
+
114
+
115
+ class Config(WireModel):
116
+ """Row config for `rlm-bindings`."""
117
+
118
+ provider: str = PROVIDER_NAME
119
+ """Which `ctx.subagents` provider `rlm.run` delegates to."""
120
+
121
+
122
+ def _render_handle(_args: JsonObject, value: Any) -> list[ContentBlock]: # noqa: ANN401
123
+ lines = [
124
+ f"admitted {value['name']} ({value['childId']}) on {value['model']}",
125
+ f"session: {value['sessionId']}",
126
+ f"workspace access: {value['grantedAccess']} (requested {value['requestedAccess']})",
127
+ ]
128
+ if value.get("note"):
129
+ lines.append(as_str(value["note"]))
130
+ lines.append("It will reply by agent message; keep working and check later.")
131
+ return text_content("\n".join(lines))
132
+
133
+
134
+ @plugin("rlm-bindings", config=Config, inject=[TOOLS, SUBAGENTS, RLM_CHILDREN])
135
+ async def apply(ctx: Context, config: Config) -> None:
136
+ """Register the `rlm_*` tools and group them as the `rlm` code namespace."""
137
+
138
+ tools = ctx.require(TOOLS)
139
+
140
+ async def run_child(args: RunArgs, run: ToolRunContext) -> dict[str, Any]:
141
+ if run.agent is None:
142
+ raise ToolCallError(RUN_TOOL, "this tool has to be called by an agent")
143
+ try:
144
+ handle = await ctx.require(SUBAGENTS).start(
145
+ config.provider,
146
+ SubagentRequest(
147
+ prompt=args.prompt,
148
+ parent=run.agent,
149
+ # The boundary the ceiling is computed in, stated rather than
150
+ # derived from the routing target (P6-31, P6-24).
151
+ scope=run.scope,
152
+ name=args.name,
153
+ reasoning_effort=args.thinking,
154
+ model=args.model,
155
+ access=args.access,
156
+ preset=args.preset,
157
+ skills=args.skills,
158
+ tools=args.tools,
159
+ ),
160
+ )
161
+ except SubagentSpawnError as error:
162
+ # The model's to handle: it can retry with a different name, a
163
+ # shallower plan, or by doing the work itself.
164
+ raise ToolCallError(RUN_TOOL, str(error)) from error
165
+ reason: DowngradeReason | None = handle.downgrade_reason
166
+ return SpawnHandle(
167
+ child_id=handle.id,
168
+ name=handle.name,
169
+ session_id=handle.session_id,
170
+ model=handle.model,
171
+ requested_access=handle.requested_access,
172
+ granted_access=handle.granted_access,
173
+ note=downgrade_text(reason) if reason is not None else None,
174
+ ).to_wire()
175
+
176
+ def list_children(_args: object, run: ToolRunContext) -> dict[str, Any]:
177
+ """The roster, folded from the parent's own log — never a side table."""
178
+ session = run.session
179
+ rows = list(ctx.require(SUBAGENTS).roster(session).values()) if session is not None else []
180
+ return {"children": rows}
181
+
182
+ async def delete_child(args: DeleteArgs, run: ToolRunContext) -> dict[str, Any]:
183
+ session = run.session
184
+ removed = (
185
+ await ctx.require(RLM_CHILDREN).delete(session, args.child_id, reason=args.reason)
186
+ if session is not None
187
+ else False
188
+ )
189
+ return {"deleted": removed, "childId": args.child_id}
190
+
191
+ tools.register(
192
+ define_tool(
193
+ RUN_TOOL,
194
+ _RUN_DESCRIPTION,
195
+ parameters=RunArgs,
196
+ output=ToolOutput(schema=SpawnHandle, render=_render_handle),
197
+ execute=run_child,
198
+ )
199
+ )
200
+ tools.register(
201
+ define_tool(
202
+ LIST_TOOL,
203
+ "Your children: name, id, status, and whether each was deleted.",
204
+ parameters={"type": "object", "properties": {}},
205
+ output={"type": "object"},
206
+ render=_render_roster,
207
+ execute=list_children,
208
+ is_concurrency_safe=True,
209
+ )
210
+ )
211
+ tools.register(
212
+ define_tool(
213
+ DELETE_TOOL,
214
+ "Revoke a child. Its transcript stays on disk; the roster keeps a tombstone.",
215
+ parameters=DeleteArgs,
216
+ output={"type": "object"},
217
+ render=_render_deleted,
218
+ execute=delete_child,
219
+ )
220
+ )
221
+
222
+ def namespace(request: CodeBindingsRequest) -> CodeBindingNamespace:
223
+ """`rlm.run` / `.list_subagents` / `.delete_subagent`, bound to the run."""
224
+ view = ctx.require(TOOLS).view(request.scope)
225
+ specs = (
226
+ ("run", RUN_TOOL, True),
227
+ ("list_subagents", LIST_TOOL, False),
228
+ ("delete_subagent", DELETE_TOOL, False),
229
+ )
230
+ bindings = [
231
+ governed_binding(request, public, definition.schema(), counts_as_spawn=spawns)
232
+ for public, tool_name, spawns in specs
233
+ # A tool restricted away for this agent is absent from the SDK block
234
+ # too, so the prompt cannot offer what a cell could not call.
235
+ if (definition := view.visible.get(tool_name)) is not None
236
+ ]
237
+ return CodeBindingNamespace(
238
+ name=NAMESPACE,
239
+ description="delegate to child agents; every spawn is governed and recorded",
240
+ bindings=tuple(bindings),
241
+ )
242
+
243
+ tools.register_code_namespace(NAMESPACE, namespace)
244
+
245
+
246
+ def _render_roster(_args: JsonObject, value: Any) -> list[ContentBlock]: # noqa: ANN401
247
+ rows = value.get("children") or []
248
+ if not rows:
249
+ return text_content("no children")
250
+ lines = [
251
+ f"- {row.get('name')} ({row.get('runId')}) "
252
+ f"{'deleted' if row.get('deleted') else row.get('status', 'queued')}"
253
+ for row in rows
254
+ ]
255
+ return text_content("\n".join(lines))
256
+
257
+
258
+ def _render_deleted(_args: JsonObject, value: Any) -> list[ContentBlock]: # noqa: ANN401
259
+ return text_content(f"deleted {value['childId']}" if value["deleted"] else "no such child")
ph_rlm/bundle.yaml ADDED
@@ -0,0 +1,174 @@
1
+ # ph-rlm: Prime Agent's design as plugins, over ph-base.
2
+ #
3
+ # The `rlm` profile is base + tui + this. Every row here is a listener on a seam
4
+ # that already exists; the only core change the bundle needs is the one the
5
+ # `code_runtime` seam already carries — `namespace` and `persistence` (C1).
6
+
7
+ # --- Code Mode --------------------------------------------------------------
8
+ # Opt-in in `ph-base`, on here: the RLM model writes Python, so the transport
9
+ # and its bindings are the whole surface it sees. `tools.mode: code` is what
10
+ # makes a native tool call refuse with the SDK route back (C6).
11
+ - id: tools
12
+ config:
13
+ mode: code
14
+
15
+ - id: tools-code-mode
16
+ name: tools-code-mode
17
+ config:
18
+ maxDispatchesPerRun: 256
19
+ maxSubagentSpawnsPerRun: 32
20
+ maxParallelSubCalls: 10
21
+
22
+ # The model-facing half of Code Mode: the transport is presented as `ipython`,
23
+ # with prime-agent's description verbatim and its result layout. `run_code` stays
24
+ # the reserved internal name — a profile renames the transport, it does not
25
+ # replace it (P3-09).
26
+ - id: rlm-presentation
27
+ name: rlm-presentation
28
+
29
+ # --- the runtime ------------------------------------------------------------
30
+ # pH's own CPython subprocess, fd 3 as the framed channel, one child per agent.
31
+ # `persistence: namespace` is admissible only because the provider promises to
32
+ # emit `kernel/snapshot` events, and the seam checks that promise when it
33
+ # registers (D6) — cross-call state invisible to the log is exactly why dsh
34
+ # withheld a persistent kernel.
35
+ - id: code-runtime-python
36
+ name: code-runtime-python
37
+ config:
38
+ # `managed` builds $PH_CACHE/runtime-venv: ph-runtime-guest, dill, and the
39
+ # Python skills. Nothing else, because everything in it is reachable by
40
+ # model code.
41
+ python: managed
42
+ cpuSeconds: 30
43
+ addressSpaceBytes: 2147483648
44
+ maxLogBytes: 65536
45
+ maxValueBytes: 65536
46
+ maxSnapshotBytes: 16777216
47
+
48
+ # --- delegation ------------------------------------------------------------
49
+ # `rlm()` as a `ctx.subagents` provider: admission is logged and the handle
50
+ # returns before the child answers, so a parent can fan out and keep working
51
+ # (P3-11). The roster the parent reads back is the fold over these events, not a
52
+ # side table, so it survives restart and compaction by construction.
53
+ # E4 note: a child gets `read` unless it asks for `write`, and that default is
54
+ # the seam's (`SubagentRequest.access`) rather than a row knob — until
55
+ # `ctx.workspace` lands (Phase 4, D21) a `write` request is recorded and
56
+ # downgraded with a reason code, because a guarantee nothing enforces must not
57
+ # be granted, and a row knob that could not change that would be a lie.
58
+ # What this host can carry, across every root — the other half of the per-parent
59
+ # figure below, and here rather than in `ph-core` because this is the bundle that
60
+ # produces `subagent` jobs at all. `phern daemon --max-concurrent-children` overrides
61
+ # it. Eight rather than four so two parents fanning out fully is the ordinary
62
+ # case and not a queue; a starting point, not a measurement.
63
+ - id: jobs
64
+ config:
65
+ concurrency:
66
+ subagent: 8
67
+
68
+ - id: rlm-subagent-provider
69
+ name: rlm-subagent-provider
70
+ config:
71
+ maxDepth: 2
72
+ # How many of one parent's children run at once; the rest queue in
73
+ # admission order rather than being refused. A deployment number, not a
74
+ # row default: the row says `null` (no cap) so a profile that never chose is
75
+ # not silently serialised. Four is a starting point, not a measurement.
76
+ maxConcurrent: 4
77
+
78
+ # The model-facing half: `rlm.run(...)` inside a cell is a governed dispatch,
79
+ # not a comm-channel RPC — so a policy row can deny or ask on spawning, and C4's
80
+ # spawn budget counts it (P3-10).
81
+ - id: rlm-bindings
82
+ name: rlm-bindings
83
+
84
+ # Agent-to-agent messages (P3-12). The nuclear-family boundary is a *guard* —
85
+ # deny-only, last, and not re-permittable by any later listener (C7) — while the
86
+ # rate limit is backpressure the tool body raises, because ending a whole cell
87
+ # over four messages in a second is not what a rate limit means.
88
+ - id: rlm-messaging
89
+ name: rlm-messaging
90
+ config:
91
+ maxMessageChars: 16384
92
+ maxPending: 20
93
+ rateCapacity: 3
94
+ rateRefillSeconds: 1.0
95
+
96
+ # The doctrine (P3-14). Sections after `tools:sdk`, because they refer to the
97
+ # surface it has just described — and the volatile facts (depth, cwd, family,
98
+ # workspace tier) are a `context()` snapshot rather than a cached section, or
99
+ # every turn that changed one would re-bill the whole prefix (A12).
100
+ - id: rlm-prompt
101
+ name: rlm-prompt
102
+
103
+ # The Continual Harness (P3-16, D14): state is a fold over `harness/refined`
104
+ # rather than a file, so a fork inherits the harness as of its boundary and a
105
+ # rollback is derivable from the event that made the change. `/refine` is a
106
+ # command, not a tool — it is something the human asks for.
107
+ - id: rlm-harness
108
+ name: rlm-harness
109
+
110
+ # I6's live example (P6-01). `harness_state.json` is written for humans and
111
+ # nothing reads it back, which is exactly the arrangement that lets a projection
112
+ # drift unnoticed — so the invariant is checked rather than assumed.
113
+ - id: rlm-harness-invariant
114
+ name: rlm-harness-invariant
115
+
116
+ # Capability-layer skills (P3-18): `SKILL.md` for the catalog, an installable
117
+ # package for the kernel. The knowledge layer (`/refine`) may point at these and
118
+ # may not create them — I7, and the reason the two share a word, not a mechanism.
119
+ # No `paths` means no skills, so the row costs nothing until a deployment
120
+ # configures one.
121
+ - id: rlm-skills-python
122
+ name: rlm-skills-python
123
+
124
+ # `subagent-task` stands down here. Both delegation shapes are real — one call
125
+ # and one answer, or a handle collected later — but this bundle *is* the second
126
+ # one: `rlm_run` admits a child that replies by agent message, with a roster and
127
+ # an inbox built around that. Offering a blocking twin beside it would put two
128
+ # ways to do one thing in the same prompt, and the model would have to guess
129
+ # which of them the rest of the tools were designed for.
130
+ - id: subagent-task
131
+ disabled: true
132
+
133
+ # "Prompt-as-a-variable" (P3-17, Q4): a corpus the model queries through
134
+ # `await tools.context_search(...)` — bindings, so each query is its own governed
135
+ # dispatch and one oversized result can be offloaded without its siblings.
136
+ # Disabled here and opt-in in `rlm-stable`: C3 gave the model governed
137
+ # `read`/`grep`/`glob`, which demoted this from a headline feature to a
138
+ # specialist one for non-file corpora. A row with no `sources` stands down anyway.
139
+ - id: rlm-context-loader
140
+ name: rlm-context-loader
141
+ disabled: true
142
+
143
+ # The row that makes `persistence: namespace` true rather than declared (D17).
144
+ # Mounted with the runtime, not optionally: the seam takes the provider's promise
145
+ # at registration, and these events are the promise being kept.
146
+ - id: rlm-kernel-snapshot
147
+ name: rlm-kernel-snapshot
148
+ config:
149
+ inlineBlobMax: 65536
150
+
151
+ # The containment ladder for an RLM session (P4-11, §4.8). The *root* agent
152
+ # stays `advisory`: a person running `rlm` is working in the directory they
153
+ # opened, and moving their agent to a checkout elsewhere is a surprise a
154
+ # deployment should have to ask for. Children go to `worktree`, because eight of
155
+ # them writing one tree concurrently is the fan-out hazard the tier exists for —
156
+ # and the parent then reviews a diff instead of trusting sibling writes (E2).
157
+ - id: containment
158
+ config:
159
+ tier: advisory
160
+ child_tier: worktree
161
+
162
+ # The rungs themselves. Layered here rather than in `ph-base` because they cost
163
+ # a checkout per agent, and inert until `containment` above asks for them.
164
+ - id: workspace-git-worktree
165
+ name: workspace-git-worktree
166
+
167
+ - id: workspace-checkpoint
168
+ name: workspace-checkpoint
169
+
170
+ - id: workspace-commands
171
+ name: workspace-commands
172
+
173
+ - id: workspace-revert
174
+ name: workspace-revert