ph-core 0.2.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.
Files changed (150) hide show
  1. ph/__init__.py +9 -0
  2. ph/agent/__init__.py +33 -0
  3. ph/agent/inbox.py +215 -0
  4. ph/agent/registry.py +186 -0
  5. ph/agent/types.py +214 -0
  6. ph/agent_loop/__init__.py +35 -0
  7. ph/agent_loop/driver.py +624 -0
  8. ph/agent_loop/invariant.py +83 -0
  9. ph/bundles/__init__.py +63 -0
  10. ph/bundles/base.yaml +335 -0
  11. ph/bundles/headless.yaml +10 -0
  12. ph/cancel.py +96 -0
  13. ph/commands/__init__.py +15 -0
  14. ph/commands/autonomous.py +231 -0
  15. ph/commands/revert.py +192 -0
  16. ph/commands/sandbox.py +359 -0
  17. ph/commands/workspaces.py +371 -0
  18. ph/cordis/__init__.py +114 -0
  19. ph/cordis/catalog.py +170 -0
  20. ph/cordis/context.py +1716 -0
  21. ph/cordis/errors.py +69 -0
  22. ph/cordis/events.py +122 -0
  23. ph/cordis/key.py +57 -0
  24. ph/cordis/loader.py +796 -0
  25. ph/cordis/plugin.py +144 -0
  26. ph/documents.py +83 -0
  27. ph/json.py +379 -0
  28. ph/keys.py +163 -0
  29. ph/lingering.py +404 -0
  30. ph/llm/__init__.py +105 -0
  31. ph/llm/adapter.py +314 -0
  32. ph/llm/assembler.py +206 -0
  33. ph/llm/dimensions.py +163 -0
  34. ph/llm/fake.py +124 -0
  35. ph/llm/media.py +317 -0
  36. ph/llm/replay.py +188 -0
  37. ph/llm/retry.py +124 -0
  38. ph/llm/structured.py +249 -0
  39. ph/llm/types.py +689 -0
  40. ph/orphans.py +309 -0
  41. ph/paths.py +350 -0
  42. ph/persistence/__init__.py +37 -0
  43. ph/persistence/checkpoint_policy.py +83 -0
  44. ph/persistence/families.py +103 -0
  45. ph/persistence/jsonl.py +455 -0
  46. ph/persistence/lease.py +78 -0
  47. ph/persistence/lineage.py +285 -0
  48. ph/persistence/protocol.py +299 -0
  49. ph/persistence/repair.py +260 -0
  50. ph/persistence/turso.py +414 -0
  51. ph/py.typed +0 -0
  52. ph/resources.py +184 -0
  53. ph/seams/__init__.py +5 -0
  54. ph/seams/_names.py +51 -0
  55. ph/seams/_registry.py +222 -0
  56. ph/seams/_restriction.py +42 -0
  57. ph/seams/approval.py +475 -0
  58. ph/seams/attachments.py +596 -0
  59. ph/seams/changes.py +418 -0
  60. ph/seams/code_runtime.py +324 -0
  61. ph/seams/code_runtime_stub.py +89 -0
  62. ph/seams/commands.py +282 -0
  63. ph/seams/compaction.py +292 -0
  64. ph/seams/containment.py +316 -0
  65. ph/seams/credentials.py +112 -0
  66. ph/seams/diagnostics.py +160 -0
  67. ph/seams/fs.py +1093 -0
  68. ph/seams/goals.py +365 -0
  69. ph/seams/invariants.py +284 -0
  70. ph/seams/jobs.py +405 -0
  71. ph/seams/permission_presets.py +184 -0
  72. ph/seams/sandbox.py +870 -0
  73. ph/seams/sandbox_allow.py +82 -0
  74. ph/seams/sandbox_egress.py +467 -0
  75. ph/seams/sandbox_local.py +886 -0
  76. ph/seams/schedule.py +470 -0
  77. ph/seams/schedule_index.py +174 -0
  78. ph/seams/scope_invariant.py +157 -0
  79. ph/seams/settings.py +92 -0
  80. ph/seams/shell.py +161 -0
  81. ph/seams/skills.py +978 -0
  82. ph/seams/skills_invariant.py +59 -0
  83. ph/seams/spill.py +263 -0
  84. ph/seams/subagents.py +1492 -0
  85. ph/seams/subprocess.py +592 -0
  86. ph/seams/telemetry.py +249 -0
  87. ph/seams/telemetry_otel.py +173 -0
  88. ph/seams/token_meter.py +347 -0
  89. ph/seams/topology.py +44 -0
  90. ph/seams/tui_screens.py +242 -0
  91. ph/seams/tui_status.py +204 -0
  92. ph/seams/uploads.py +345 -0
  93. ph/seams/user_questions.py +266 -0
  94. ph/seams/workspace.py +2161 -0
  95. ph/seams/workspace_agentfs.py +721 -0
  96. ph/seams/workspace_git.py +917 -0
  97. ph/seams/workspace_jj.py +1045 -0
  98. ph/seams/workspace_provision.py +345 -0
  99. ph/seams/workspace_scratch.py +131 -0
  100. ph/selectors.py +168 -0
  101. ph/session/__init__.py +101 -0
  102. ph/session/derive.py +67 -0
  103. ph/session/events.py +232 -0
  104. ph/session/folds.py +147 -0
  105. ph/session/invariant.py +46 -0
  106. ph/session/json.py +198 -0
  107. ph/session/known_event_types.py +370 -0
  108. ph/session/request_header.py +141 -0
  109. ph/session/session.py +711 -0
  110. ph/session/store.py +434 -0
  111. ph/session/surface.py +349 -0
  112. ph/system_prompt/__init__.py +25 -0
  113. ph/system_prompt/assembly.py +383 -0
  114. ph/system_prompt/memory.py +201 -0
  115. ph/testing/__init__.py +159 -0
  116. ph/testing/anthropic_wire.py +66 -0
  117. ph/testing/builders.py +600 -0
  118. ph/testing/diagnostics.py +23 -0
  119. ph/testing/folds.py +346 -0
  120. ph/testing/git.py +91 -0
  121. ph/testing/jj.py +82 -0
  122. ph/testing/skills.py +58 -0
  123. ph/testing/stub_sandbox.py +38 -0
  124. ph/testing/stub_subagent.py +96 -0
  125. ph/testing/stub_workspace.py +156 -0
  126. ph/text.py +152 -0
  127. ph/tools/__init__.py +90 -0
  128. ph/tools/batch.py +294 -0
  129. ph/tools/builtin/__init__.py +5 -0
  130. ph/tools/builtin/ask_user.py +128 -0
  131. ph/tools/builtin/attach_tool.py +197 -0
  132. ph/tools/builtin/bash_tool.py +127 -0
  133. ph/tools/builtin/fs_tools.py +279 -0
  134. ph/tools/builtin/subagent_task.py +224 -0
  135. ph/tools/code_mode.py +554 -0
  136. ph/tools/definition.py +739 -0
  137. ph/tools/errors.py +125 -0
  138. ph/tools/invariant.py +58 -0
  139. ph/tools/json_schema.py +296 -0
  140. ph/tools/presentation.py +199 -0
  141. ph/tools/prompt.py +29 -0
  142. ph/tools/registry.py +1167 -0
  143. ph/tools/sdk.py +133 -0
  144. ph/tools/timeout.py +57 -0
  145. ph/wire.py +236 -0
  146. ph_core-0.2.0.dist-info/METADATA +239 -0
  147. ph_core-0.2.0.dist-info/RECORD +150 -0
  148. ph_core-0.2.0.dist-info/WHEEL +4 -0
  149. ph_core-0.2.0.dist-info/entry_points.txt +75 -0
  150. ph_core-0.2.0.dist-info/licenses/LICENSE +21 -0
ph/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """pH — a Python agent harness ported from DeepSeek Harness.
2
+
3
+ The core distribution. See `docs/dev-notes/phase-0.md` for what Phase 0 landed
4
+ and `plans/Implementation_Plan.md` for the work breakdown.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.2.0"
ph/agent/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """`ph.agent` — the agent handle, its inbox, and the registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .inbox import Inbox, InboxNotifications, InboxTarget
6
+ from .registry import AgentRegistry
7
+ from .types import (
8
+ AgentCancelCause,
9
+ AgentOptions,
10
+ AgentStatus,
11
+ PreStepDecision,
12
+ PreStepRequest,
13
+ RequestErrorAction,
14
+ RequestFailure,
15
+ RequestProposal,
16
+ TurnEndReason,
17
+ )
18
+
19
+ __all__ = [
20
+ "AgentCancelCause",
21
+ "AgentOptions",
22
+ "AgentRegistry",
23
+ "AgentStatus",
24
+ "Inbox",
25
+ "InboxNotifications",
26
+ "InboxTarget",
27
+ "PreStepDecision",
28
+ "PreStepRequest",
29
+ "RequestErrorAction",
30
+ "RequestFailure",
31
+ "RequestProposal",
32
+ "TurnEndReason",
33
+ ]
ph/agent/inbox.py ADDED
@@ -0,0 +1,215 @@
1
+ """The agent inbox: durable, replayable pending input.
2
+
3
+ Three delivery semantics, ported exactly, because the difference is what makes
4
+ steering feel instant and injection feel invisible:
5
+
6
+ | call | lands at | wakes an idle agent |
7
+ |---|---|---|
8
+ | `followup(msg)` | next **turn** | yes |
9
+ | `steer(msg)` | next **step** | yes |
10
+ | `inject(msg)` | next **step** | **no** — it waits for another message |
11
+
12
+ Every mutation is logged as `agent/inbox/spliced` *before* the projection
13
+ changes, so a resumed agent reconstructs its queue from the log rather than
14
+ losing whatever the user typed before the crash.
15
+
16
+ @module ph.agent.inbox
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from typing import Literal, TypeAlias
24
+
25
+ from pydantic import ConfigDict
26
+
27
+ from ..json import JsonObject
28
+ from ..llm.types import Message
29
+ from ..session import Session
30
+ from ..wire import WireModel
31
+
32
+ __all__ = ["Inbox", "InboxNotifications", "InboxTarget"]
33
+
34
+ InboxTarget: TypeAlias = Literal["next-turn", "next-step"]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class InboxNotifications:
39
+ """Live mirrors of durable inbox mutations."""
40
+
41
+ inserted: Callable[[Message], None]
42
+ discarded: Callable[[Message], None]
43
+ claimed: Callable[[Message, int], None]
44
+
45
+
46
+ class InboxSplice(WireModel):
47
+ """One inbox mutation, as `agent/inbox/spliced` carries it.
48
+
49
+ Declared rather than hand-built and hand-parsed, the way every other durable
50
+ payload is (`UserQuestion`, `RequestContext`, `Message`): `WireModel` owns the
51
+ camelCase aliases and the log's frozen mapping, so `_mutate` writing a key and
52
+ `_apply` reading one cannot drift, and `ph_app.tui.adapter` has something to
53
+ validate against instead of re-spelling `inserted` and `removedCount`.
54
+
55
+ `removed_count` is `None` rather than `0` when absent, and `outcome` likewise,
56
+ because `to_wire()` omits `None` — an insert-only splice must not start
57
+ emitting `"removedCount": 0` into logs that never carried it.
58
+
59
+ `extra="ignore"` overrides `WireModel`'s `forbid`: a log is read by builds
60
+ older than the one that wrote it, and a key added later must be skipped on
61
+ replay rather than condemn the whole session.
62
+ """
63
+
64
+ model_config = ConfigDict(extra="ignore")
65
+
66
+ target: InboxTarget
67
+ start: int
68
+ inserted: list[Message]
69
+ removed_count: int | None = None
70
+ outcome: Literal["canceled"] | None = None
71
+ """Whether the removed messages were canceled or consumed. Written and not
72
+ yet read back: live, the difference is `_notify.discarded` against
73
+ `_notify.claimed`, and on replay this key is the only thing that still knows
74
+ which happened."""
75
+
76
+
77
+ class Inbox:
78
+ """A replay-once projection that incrementally consumes later splices."""
79
+
80
+ __slots__ = ("_notify", "_session", "_state")
81
+
82
+ def __init__(self, session: Session, notifications: InboxNotifications) -> None:
83
+ self._session = session
84
+ self._notify = notifications
85
+ self._state: dict[str, list[Message]] = {"next-turn": [], "next-step": []}
86
+ # Replay only this lifecycle's splices: a fork inherits its parent's
87
+ # transcript, not its parent's unanswered queue.
88
+ for event in session.events[session.header.seed_length or 0 :]:
89
+ if event.type != "agent/inbox/spliced":
90
+ continue
91
+ try:
92
+ self._apply(event.data)
93
+ except ValueError as error:
94
+ raise ValueError(
95
+ f"invalid persisted inbox splice at session seq {event.seq}"
96
+ ) from error
97
+
98
+ @property
99
+ def next_turn(self) -> tuple[Message, ...]:
100
+ return tuple(self._state["next-turn"])
101
+
102
+ @property
103
+ def next_step(self) -> tuple[Message, ...]:
104
+ return tuple(self._state["next-step"])
105
+
106
+ @property
107
+ def has_pending(self) -> bool:
108
+ return bool(self._state["next-turn"] or self._state["next-step"])
109
+
110
+ def clear(self) -> None:
111
+ """Durably cancel all pending input, next-step before next-turn."""
112
+ self.splice("next-step", 0, len(self._state["next-step"]), [])
113
+ self.splice("next-turn", 0, len(self._state["next-turn"]), [])
114
+
115
+ def claim(self, target: InboxTarget, turn: int) -> list[Message]:
116
+ """Take the batch proposed for one step.
117
+
118
+ Always every pending `next-step` message, plus — at a turn boundary —
119
+ exactly one queued turn. Claiming more than one turn would merge two
120
+ user prompts into one model call.
121
+ """
122
+ claimed = self._mutate("next-step", 0, len(self._state["next-step"]), [], False)
123
+ if target == "next-turn":
124
+ claimed.extend(self._mutate("next-turn", 0, 1, [], False))
125
+ for message in claimed:
126
+ self._notify.claimed(message, turn)
127
+ return claimed
128
+
129
+ def append(self, target: InboxTarget, message: Message) -> None:
130
+ self.splice(target, len(self._state[target]), 0, [message])
131
+
132
+ def splice(
133
+ self, target: InboxTarget, start: int, delete_count: int, inserted: Sequence[Message]
134
+ ) -> list[Message]:
135
+ """Standard splice semantics, durably recorded; removed messages are canceled."""
136
+ return self._mutate(target, start, delete_count, list(inserted), True)
137
+
138
+ # ------------------------------------------------------------- internals --
139
+
140
+ def _mutate(
141
+ self,
142
+ target: InboxTarget,
143
+ start: int,
144
+ delete_count: int,
145
+ inserted: list[Message],
146
+ discard_removed: bool,
147
+ ) -> list[Message]:
148
+ pending = self._state[target]
149
+ start = min(max(start, 0), len(pending))
150
+ delete_count = min(max(delete_count, 0), len(pending) - start)
151
+ if delete_count == 0 and not inserted:
152
+ return []
153
+ splice = InboxSplice(
154
+ target=target,
155
+ start=start,
156
+ inserted=inserted,
157
+ removed_count=delete_count or None,
158
+ outcome="canceled" if delete_count and discard_removed else None,
159
+ )
160
+ # The one rule a write can still break. The coordinates are clamped above
161
+ # and the values are already typed, so there is nothing here to parse.
162
+ self._unique(target, start, delete_count, inserted)
163
+ # The durable event commits BEFORE the live projection mutates, so a
164
+ # synchronous `session/event` observer sees the pre-splice lists and can
165
+ # reconstruct exactly what was removed from the normalized coordinates.
166
+ self._session.append("agent/inbox/spliced", splice.to_wire())
167
+ removed = pending[start : start + delete_count]
168
+ pending[start : start + delete_count] = inserted
169
+ if discard_removed:
170
+ for message in removed:
171
+ self._notify.discarded(message)
172
+ for message in inserted:
173
+ self._notify.inserted(message)
174
+ return list(removed)
175
+
176
+ def _apply(self, splice: JsonObject) -> None:
177
+ """Replay one logged splice, or refuse the log."""
178
+ parsed = InboxSplice.model_validate(splice)
179
+ pending = self._state[parsed.target]
180
+ removed_count = parsed.removed_count or 0
181
+ self._placed(parsed.target, parsed.start, removed_count)
182
+ self._unique(parsed.target, parsed.start, removed_count, parsed.inserted)
183
+ pending[parsed.start : parsed.start + removed_count] = list(parsed.inserted)
184
+
185
+ def _placed(self, target: InboxTarget, start: int, removed_count: int) -> None:
186
+ """That the coordinates fall inside the queue they name.
187
+
188
+ Replay only: `_mutate` clamps both into range before it writes, so on the
189
+ write path these branches cannot fail and checking them there would read
190
+ as though they could.
191
+ """
192
+ pending = self._state[target]
193
+ if not 0 <= start <= len(pending) or not 0 <= removed_count <= len(pending) - start:
194
+ raise ValueError("invalid inbox splice")
195
+
196
+ def _unique(
197
+ self, target: InboxTarget, start: int, removed_count: int, inserted: Sequence[Message]
198
+ ) -> None:
199
+ """That no message id would end up pending twice, across both queues.
200
+
201
+ The one rule both paths share, so it takes messages rather than a payload:
202
+ `_mutate` is holding the `Message` list it just built and has no reason to
203
+ serialize it and read the ids back out.
204
+ """
205
+ pending = self._state[target]
206
+ other: InboxTarget = "next-step" if target == "next-turn" else "next-turn"
207
+ candidate = [
208
+ *(m.id for m in pending[:start]),
209
+ *(m.id for m in inserted),
210
+ *(m.id for m in pending[start + removed_count :]),
211
+ *(m.id for m in self._state[other]),
212
+ ]
213
+ if len(set(candidate)) != len(candidate):
214
+ duplicate = next(i for i in candidate if candidate.count(i) > 1)
215
+ raise ValueError(f'message "{duplicate}" is already pending')
ph/agent/registry.py ADDED
@@ -0,0 +1,186 @@
1
+ """`ctx.agents` — agent creation, and the events every agent publishes.
2
+
3
+ An agent is a handle: an id, a session, a **scoped context**, an inbox, a
4
+ status and a cancel. The registry owns the scope — it creates it, provides the
5
+ handle into it as `agent`, and disposes it — and the driver runs inside it.
6
+ That split is what keeps the loop a replaceable row (invariant I1): a second
7
+ driver inherits the scope tree, the `agent` provision and the lifecycle events
8
+ without reproducing them.
9
+
10
+ @module ph.agent.registry
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+
18
+ from ..cordis import Context, events, plugin
19
+ from ..keys import AGENT, AGENTS, SESSIONS
20
+ from ..session import Session
21
+ from .types import (
22
+ AgentDriver,
23
+ AgentHandle,
24
+ AgentOptions,
25
+ PreStepRequest,
26
+ RequestFailure,
27
+ RequestProposal,
28
+ )
29
+
30
+ __all__ = ["AgentRegistry", "apply"]
31
+
32
+ events.declare("agent/created", "emit", owner="ph.agent", doc="An agent handle was created.")
33
+ events.declare("agent/disposed", "emit", owner="ph.agent", doc="An agent handle was disposed.")
34
+ events.declare(
35
+ "agent/status", "emit", owner="ph.agent", doc="An agent moved between idle and running."
36
+ )
37
+ events.declare(
38
+ "agent/error", "emit", owner="ph.agent", doc="A failure was reported at its live boundary."
39
+ )
40
+ events.declare("agent/session-start", "emit", owner="ph.agent", doc="An agent bound a session.")
41
+ events.declare(
42
+ "agent/inbox/inserted", "emit", owner="ph.agent", doc="A message entered a pending list."
43
+ )
44
+ events.declare(
45
+ "agent/inbox/claimed", "emit", owner="ph.agent", doc="A pending message was claimed for a turn."
46
+ )
47
+ events.declare(
48
+ "agent/inbox/discarded", "emit", owner="ph.agent", doc="A pending message was canceled."
49
+ )
50
+ events.declare(
51
+ "agent/pre-step",
52
+ "waterfall",
53
+ PreStepRequest,
54
+ owner="ph.agent",
55
+ doc="The authoritative reject-or-enter decision for one step.",
56
+ )
57
+ events.declare(
58
+ "agent/request",
59
+ "waterfall",
60
+ RequestProposal,
61
+ owner="ph.agent",
62
+ doc="Proposes the call config for one request.",
63
+ )
64
+ events.declare(
65
+ "agent/request-error",
66
+ "waterfall",
67
+ RequestFailure,
68
+ owner="ph.agent",
69
+ doc="A failed request; a listener may answer with a retry.",
70
+ )
71
+ events.declare(
72
+ "agent/turn-stopping",
73
+ "serial",
74
+ owner="ph.agent",
75
+ doc="Last chance to keep a turn alive; a listener objects by steering.",
76
+ )
77
+
78
+ DriverFactory = Callable[[Context, Session, AgentOptions], AgentDriver]
79
+ """Builds a driver inside an already-created agent scope."""
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class AgentRegistry:
84
+ """The service published as `ctx.agents`."""
85
+
86
+ ctx: Context
87
+ driver_factory: DriverFactory | None = None
88
+ _agents: dict[str, AgentDriver] = field(default_factory=dict)
89
+
90
+ def register_driver(self, factory: DriverFactory) -> Callable[[], None]:
91
+ """Claim the driver used by `create()`.
92
+
93
+ The loop is a plugin like any other (invariant I1): swapping it is a row
94
+ change, not a fork.
95
+ """
96
+ self.driver_factory = factory
97
+
98
+ def release() -> None:
99
+ if self.driver_factory is factory:
100
+ self.driver_factory = None
101
+
102
+ return release
103
+
104
+ def create(
105
+ self,
106
+ session: Session,
107
+ options: AgentOptions | None = None,
108
+ *,
109
+ parent: AgentHandle | None = None,
110
+ ) -> AgentDriver:
111
+ """Build an agent and the scope it owns.
112
+
113
+ **`parent` puts the child's scope inside its parent's** (P6-27), which is what
114
+ makes containment structural instead of materialized. Three things then stop being
115
+ remembered:
116
+
117
+ * **visibility inherits**, because `isolation_chain` reaches the parent's layers
118
+ and the existing masking already walks it;
119
+ * **disposal cascades**, because `Context.dispose` already unwinds `_children`;
120
+ * **a parent can reach its child**, which is what makes a supervisor able to
121
+ diagnose one.
122
+
123
+ Optional and defaulting to a root agent with no parent.
124
+ """
125
+ if self.driver_factory is None:
126
+ raise RuntimeError("no agent driver is registered; mount an agent-loop row")
127
+ base = parent.ctx if parent is not None else self.ctx
128
+ scope = base.scope(f"agent:{session.id}")
129
+ agent = self.driver_factory(scope, session, options or AgentOptions())
130
+ scope.provide(AGENT, agent)
131
+ self._agents[agent.id] = agent
132
+ # **The roster entry is an effect of the scope it describes**, which it
133
+ # had to become the moment P6-27 nested agents: `dispose(agent_id)` used
134
+ # to be the only thing that popped `_agents`, and a parent's
135
+ # `Context.dispose` now tears a child's scope down without going through
136
+ # it. That left `agents.get(child)` handing back a live-looking handle
137
+ # over a dead context, `agents.list()` — "every live agent, for a sweep
138
+ # that must visit all of them" — including it, and `agent/disposed`
139
+ # never firing. Structural for the same reason the lifetime is: a second
140
+ # provider that nests without registering a teardown effect of its own
141
+ # cannot leak a handle it never had to remember.
142
+ scope.add_disposer(lambda: self._forget(agent), label=f"agents.entry({agent.id})")
143
+ scope.emit("agent/created", agent)
144
+ scope.emit("agent/session-start", agent, session)
145
+ return agent
146
+
147
+ def get(self, agent_id: str) -> AgentDriver | None:
148
+ """The live agent by id, or `None`. The symmetric read to `ctx.sessions.get`.
149
+
150
+ A plugin that has an agent id — a runtime keyed by it, a policy folding
151
+ its session — reaches the agent's scope and session through here, instead
152
+ of shadowing the registry with its own `agent/created` side table.
153
+ """
154
+ return self._agents.get(agent_id)
155
+
156
+ def list(self) -> list[AgentDriver]:
157
+ """Every live agent, for a sweep that must visit all of them."""
158
+ return list(self._agents.values())
159
+
160
+ def _forget(self, agent: AgentDriver) -> None:
161
+ """Drop the roster entry and announce it, once, whoever unwound the scope.
162
+
163
+ Both doors reach here: `dispose(agent_id)` below, and a parent scope
164
+ cascading into this one. `emit` before the pop is deliberate — a listener
165
+ asking `agents.get` about the agent it is being told about should still
166
+ find it — and the pop is idempotent, so the explicit path calling
167
+ `scope.dispose()` afterwards runs this a second time and does nothing.
168
+ """
169
+ if self._agents.pop(agent.id, None) is None:
170
+ return
171
+ agent.ctx.emit("agent/disposed", agent)
172
+
173
+ async def dispose(self, agent_id: str) -> None:
174
+ agent = self._agents.get(agent_id)
175
+ if agent is None:
176
+ return
177
+ await agent.dispose()
178
+ # `_forget` rides the scope's own teardown, so the announcement and the
179
+ # roster drop happen exactly once whichever way the scope goes.
180
+ await agent.ctx.dispose()
181
+
182
+
183
+ @plugin("agent", inject=[SESSIONS])
184
+ async def apply(ctx: Context, config: None) -> None:
185
+ """Mount the agent registry."""
186
+ ctx.provide(AGENTS, AgentRegistry(ctx=ctx))
ph/agent/types.py ADDED
@@ -0,0 +1,214 @@
1
+ """Agent-facing vocabulary: cancellation causes, decisions, turn endings, and
2
+ the payloads the agent waterfalls carry.
3
+
4
+ The waterfall payloads are frozen dataclasses rather than string-keyed dicts so
5
+ a listener's signature *is* the contract: the limits and permissions plugins
6
+ (Phase 4) that own `agent/pre-step`, and the retry plugin on
7
+ `agent/request-error`, read fields the type checker knows about, and
8
+ `phern events` can name the payload beside the event.
9
+
10
+ @module ph.agent.types
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
17
+
18
+ from ..cancel import Cancellation
19
+ from ..llm.types import LlmCallConfig, LlmFailure, Message
20
+ from ..wire import WireDataclass
21
+
22
+ if TYPE_CHECKING:
23
+ from ..cordis import Context
24
+ from ..session import Session
25
+ from .inbox import Inbox
26
+
27
+ __all__ = [
28
+ "AgentCancelCause",
29
+ "AgentDriver",
30
+ "AgentHandle",
31
+ "AgentOptions",
32
+ "AgentStatus",
33
+ "PreStepDecision",
34
+ "PreStepRequest",
35
+ "RequestErrorAction",
36
+ "RequestFailure",
37
+ "RequestProposal",
38
+ "TurnEndReason",
39
+ ]
40
+
41
+ AgentStatus: TypeAlias = Literal["idle", "running"]
42
+
43
+
44
+ class AgentHandle(Protocol):
45
+ """What a seam, a tool body or a waterfall payload may assume about an agent.
46
+
47
+ Six read-only facts, and deliberately no more: this is the surface the
48
+ seams actually read, and it is the surface `ph.testing.StubAgent` has, so a
49
+ test can stand in an agent without standing up a loop. A Protocol rather than
50
+ the driver class for two reasons that reinforce each other: two
51
+ implementations exist (the loop and the stub), and the driver imports this
52
+ module, so naming it here would be a cycle.
53
+
54
+ **`signal` is the sixth, and it is here because two seams already reached
55
+ for it.** `AgentDriver.cancel` is the verb; the token it trips is the fact,
56
+ and a row that *asks a human* has to know whether the work is still wanted
57
+ before it puts a question on someone's screen. `ph_stabilize.permissions_fs`
58
+ and the RLM harness both wrote `getattr(agent, "signal", None)` for it — no
59
+ agent had the attribute, so the default answered every call.
60
+
61
+ `Cancellation`, not `CancelToken`: a seam may ask whether the work is still
62
+ wanted and may not end it. Non-optional because every driver owns one —
63
+ `_Phase.token` is built at construction and is what `session/cancel` trips,
64
+ through the verb the TUI and RPC share.
65
+
66
+ **`status` is a fact, not a verb**, which is why it is here and not on
67
+ `AgentDriver` beside `cancel`. It was on the driver, so a seam holding a
68
+ handle could not ask whether the agent was busy — and `compaction` asked with
69
+ `getattr(agent, "status", "idle")`, whose default answered *idle* for every
70
+ stub, in the one place that refuses to compact a working agent.
71
+
72
+ Properties rather than attributes, so that a driver whose `session` is a
73
+ `Session` satisfies a reader that accepts `Session | None`: a Protocol
74
+ attribute is settable and therefore invariant, and would refuse exactly the
75
+ implementation this describes.
76
+ """
77
+
78
+ @property
79
+ def id(self) -> str: ...
80
+ @property
81
+ def ctx(self) -> Context: ...
82
+ @property
83
+ def session(self) -> Session | None: ...
84
+ @property
85
+ def options(self) -> AgentOptions: ...
86
+ @property
87
+ def status(self) -> AgentStatus: ...
88
+ @property
89
+ def signal(self) -> Cancellation: ...
90
+
91
+
92
+ class AgentDriver(AgentHandle, Protocol):
93
+ """What the registry, the daemon and a supervising row may *do* to an agent.
94
+
95
+ The handle plus the verbs — the surface `AgentRegistry.create` hands back and
96
+ `ph_app`'s supervisor drives. Separate from `AgentHandle` because most callers
97
+ have no business steering: a seam that could reach `cancel` through a
98
+ parameter typed for reading is a seam that will, eventually.
99
+ """
100
+
101
+ @property
102
+ def inbox(self) -> Inbox: ...
103
+ def steer(self, message: Message) -> None: ...
104
+ def inject(self, message: Message) -> None: ...
105
+ def followup(self, message: Message) -> None: ...
106
+ def interject(self, message: Message) -> None: ...
107
+ def cancel(self, cause: AgentCancelCause, *, keep_inbox: bool = False) -> None: ...
108
+ async def run(self) -> None: ...
109
+ async def prompt(self, text: str) -> None: ...
110
+ async def dispose(self) -> None: ...
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class AgentCancelCause(WireDataclass):
115
+ """Why an active driver was canceled."""
116
+
117
+ kind: Literal["user", "parent", "hook", "disposed", "legacy"]
118
+ reason: str | None = None
119
+ """Set only for `hook`: which listener objected, and why."""
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class TurnEndReason(WireDataclass):
124
+ """Why a turn ended.
125
+
126
+ `error` always carries structured facts rather than a flattened string: the
127
+ code is what a retry policy and a later reader both route on.
128
+ """
129
+
130
+ kind: Literal["completed", "aborted", "blocked", "error", "max-tokens", "interrupted"]
131
+ reason: AgentCancelCause | None = None
132
+ error: LlmFailure | None = None
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class PreStepRequest:
137
+ """`agent/pre-step`: the batch about to enter a step, before the decision.
138
+
139
+ **`session` is not optional here**, though `AgentHandle.session` is. A
140
+ pre-step is a step about to happen, so there is a turn, so there is a
141
+ session — and the one production constructor is the driver, whose own
142
+ `session` is a `Session`. The handle keeps its `| None` for the callers that
143
+ hold one outside a turn, and for `StubAgent`. Three listeners on this chain
144
+ each re-derived it from the handle and each guarded the `None` differently.
145
+ """
146
+
147
+ agent: AgentHandle
148
+ session: Session
149
+ messages: tuple[Message, ...]
150
+ turn: int
151
+ step: int
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class PreStepDecision:
156
+ """The authoritative decision about whether a step happens, and with what.
157
+
158
+ `reject` closes the turn as `blocked`; `enter` supplies the exact messages
159
+ the step will log. A limits or permissions plugin owns this decision by
160
+ returning without calling `next()`.
161
+ """
162
+
163
+ kind: Literal["reject", "enter"]
164
+ messages: tuple[Message, ...] = ()
165
+ reason: str = ""
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class RequestProposal:
170
+ """`agent/request`: the call config the loop proposes for one request."""
171
+
172
+ agent: AgentHandle
173
+ turn: int
174
+ step: int
175
+ config: LlmCallConfig
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class RequestFailure:
180
+ """`agent/request-error`: a request that ended in `error` or `aborted`."""
181
+
182
+ agent: AgentHandle
183
+ turn: int
184
+ step: int
185
+ provider: str
186
+ failure: LlmFailure
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class RequestErrorAction:
191
+ """What to do about a failed model request: retry, or let it stand."""
192
+
193
+ kind: Literal["retry"]
194
+ delay_ms: int = 0
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class AgentOptions:
199
+ """Per-agent settings resolved at creation."""
200
+
201
+ provider: str = ""
202
+ model: str = ""
203
+ max_tokens: int | None = None
204
+ temperature: float | None = None
205
+ reasoning_effort: str | None = None
206
+
207
+ def seed_config(self) -> LlmCallConfig:
208
+ return LlmCallConfig(
209
+ provider=self.provider,
210
+ model=self.model,
211
+ reasoning_effort=self.reasoning_effort,
212
+ temperature=self.temperature,
213
+ max_tokens=self.max_tokens,
214
+ )
@@ -0,0 +1,35 @@
1
+ """`ph.agent_loop` — the ReAct driver, mounted as a row like anything else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+
7
+ from ..agent.registry import DriverFactory
8
+ from ..cordis import Context, plugin
9
+ from ..keys import AGENTS, LLM, SESSIONS, SYSTEM_PROMPT
10
+ from ..wire import WireModel
11
+ from .driver import AgentCanceled, ReactLoopAgent
12
+
13
+ __all__ = ["AgentCanceled", "Config", "ReactLoopAgent", "apply"]
14
+
15
+
16
+ class Config(WireModel):
17
+ """Row config for the loop.
18
+
19
+ `max_parallel_tool_calls` is the native batch pool width — an agent-loop
20
+ setting, as in dsh (`agentLoop.config.maxParallelToolCalls`), distinct from
21
+ the Code Mode row's `max_parallel_sub_calls` for one program's sub-calls.
22
+ """
23
+
24
+ max_parallel_tool_calls: int = 10
25
+
26
+
27
+ @plugin("agent-loop", config=Config, inject=[AGENTS, LLM, SESSIONS, SYSTEM_PROMPT])
28
+ async def apply(ctx: Context, config: Config) -> None:
29
+ """Register `ReactLoopAgent` as the driver `ctx.agents.create()` uses."""
30
+ # Annotated, so mypy holds the driver to `AgentDriver` here — `ctx.agents` is
31
+ # untyped, so `register_driver` would take anything.
32
+ factory: DriverFactory = partial(
33
+ ReactLoopAgent, max_parallel_tool_calls=config.max_parallel_tool_calls
34
+ )
35
+ ctx.add_disposer(ctx.require(AGENTS).register_driver(factory), label="agent-loop")