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.
- ph/__init__.py +9 -0
- ph/agent/__init__.py +33 -0
- ph/agent/inbox.py +215 -0
- ph/agent/registry.py +186 -0
- ph/agent/types.py +214 -0
- ph/agent_loop/__init__.py +35 -0
- ph/agent_loop/driver.py +624 -0
- ph/agent_loop/invariant.py +83 -0
- ph/bundles/__init__.py +63 -0
- ph/bundles/base.yaml +335 -0
- ph/bundles/headless.yaml +10 -0
- ph/cancel.py +96 -0
- ph/commands/__init__.py +15 -0
- ph/commands/autonomous.py +231 -0
- ph/commands/revert.py +192 -0
- ph/commands/sandbox.py +359 -0
- ph/commands/workspaces.py +371 -0
- ph/cordis/__init__.py +114 -0
- ph/cordis/catalog.py +170 -0
- ph/cordis/context.py +1716 -0
- ph/cordis/errors.py +69 -0
- ph/cordis/events.py +122 -0
- ph/cordis/key.py +57 -0
- ph/cordis/loader.py +796 -0
- ph/cordis/plugin.py +144 -0
- ph/documents.py +83 -0
- ph/json.py +379 -0
- ph/keys.py +163 -0
- ph/lingering.py +404 -0
- ph/llm/__init__.py +105 -0
- ph/llm/adapter.py +314 -0
- ph/llm/assembler.py +206 -0
- ph/llm/dimensions.py +163 -0
- ph/llm/fake.py +124 -0
- ph/llm/media.py +317 -0
- ph/llm/replay.py +188 -0
- ph/llm/retry.py +124 -0
- ph/llm/structured.py +249 -0
- ph/llm/types.py +689 -0
- ph/orphans.py +309 -0
- ph/paths.py +350 -0
- ph/persistence/__init__.py +37 -0
- ph/persistence/checkpoint_policy.py +83 -0
- ph/persistence/families.py +103 -0
- ph/persistence/jsonl.py +455 -0
- ph/persistence/lease.py +78 -0
- ph/persistence/lineage.py +285 -0
- ph/persistence/protocol.py +299 -0
- ph/persistence/repair.py +260 -0
- ph/persistence/turso.py +414 -0
- ph/py.typed +0 -0
- ph/resources.py +184 -0
- ph/seams/__init__.py +5 -0
- ph/seams/_names.py +51 -0
- ph/seams/_registry.py +222 -0
- ph/seams/_restriction.py +42 -0
- ph/seams/approval.py +475 -0
- ph/seams/attachments.py +596 -0
- ph/seams/changes.py +418 -0
- ph/seams/code_runtime.py +324 -0
- ph/seams/code_runtime_stub.py +89 -0
- ph/seams/commands.py +282 -0
- ph/seams/compaction.py +292 -0
- ph/seams/containment.py +316 -0
- ph/seams/credentials.py +112 -0
- ph/seams/diagnostics.py +160 -0
- ph/seams/fs.py +1093 -0
- ph/seams/goals.py +365 -0
- ph/seams/invariants.py +284 -0
- ph/seams/jobs.py +405 -0
- ph/seams/permission_presets.py +184 -0
- ph/seams/sandbox.py +870 -0
- ph/seams/sandbox_allow.py +82 -0
- ph/seams/sandbox_egress.py +467 -0
- ph/seams/sandbox_local.py +886 -0
- ph/seams/schedule.py +470 -0
- ph/seams/schedule_index.py +174 -0
- ph/seams/scope_invariant.py +157 -0
- ph/seams/settings.py +92 -0
- ph/seams/shell.py +161 -0
- ph/seams/skills.py +978 -0
- ph/seams/skills_invariant.py +59 -0
- ph/seams/spill.py +263 -0
- ph/seams/subagents.py +1492 -0
- ph/seams/subprocess.py +592 -0
- ph/seams/telemetry.py +249 -0
- ph/seams/telemetry_otel.py +173 -0
- ph/seams/token_meter.py +347 -0
- ph/seams/topology.py +44 -0
- ph/seams/tui_screens.py +242 -0
- ph/seams/tui_status.py +204 -0
- ph/seams/uploads.py +345 -0
- ph/seams/user_questions.py +266 -0
- ph/seams/workspace.py +2161 -0
- ph/seams/workspace_agentfs.py +721 -0
- ph/seams/workspace_git.py +917 -0
- ph/seams/workspace_jj.py +1045 -0
- ph/seams/workspace_provision.py +345 -0
- ph/seams/workspace_scratch.py +131 -0
- ph/selectors.py +168 -0
- ph/session/__init__.py +101 -0
- ph/session/derive.py +67 -0
- ph/session/events.py +232 -0
- ph/session/folds.py +147 -0
- ph/session/invariant.py +46 -0
- ph/session/json.py +198 -0
- ph/session/known_event_types.py +370 -0
- ph/session/request_header.py +141 -0
- ph/session/session.py +711 -0
- ph/session/store.py +434 -0
- ph/session/surface.py +349 -0
- ph/system_prompt/__init__.py +25 -0
- ph/system_prompt/assembly.py +383 -0
- ph/system_prompt/memory.py +201 -0
- ph/testing/__init__.py +159 -0
- ph/testing/anthropic_wire.py +66 -0
- ph/testing/builders.py +600 -0
- ph/testing/diagnostics.py +23 -0
- ph/testing/folds.py +346 -0
- ph/testing/git.py +91 -0
- ph/testing/jj.py +82 -0
- ph/testing/skills.py +58 -0
- ph/testing/stub_sandbox.py +38 -0
- ph/testing/stub_subagent.py +96 -0
- ph/testing/stub_workspace.py +156 -0
- ph/text.py +152 -0
- ph/tools/__init__.py +90 -0
- ph/tools/batch.py +294 -0
- ph/tools/builtin/__init__.py +5 -0
- ph/tools/builtin/ask_user.py +128 -0
- ph/tools/builtin/attach_tool.py +197 -0
- ph/tools/builtin/bash_tool.py +127 -0
- ph/tools/builtin/fs_tools.py +279 -0
- ph/tools/builtin/subagent_task.py +224 -0
- ph/tools/code_mode.py +554 -0
- ph/tools/definition.py +739 -0
- ph/tools/errors.py +125 -0
- ph/tools/invariant.py +58 -0
- ph/tools/json_schema.py +296 -0
- ph/tools/presentation.py +199 -0
- ph/tools/prompt.py +29 -0
- ph/tools/registry.py +1167 -0
- ph/tools/sdk.py +133 -0
- ph/tools/timeout.py +57 -0
- ph/wire.py +236 -0
- ph_core-0.2.0.dist-info/METADATA +239 -0
- ph_core-0.2.0.dist-info/RECORD +150 -0
- ph_core-0.2.0.dist-info/WHEEL +4 -0
- ph_core-0.2.0.dist-info/entry_points.txt +75 -0
- 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")
|