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/agent_loop/driver.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""`ReactLoopAgent` — one session driven through turn and step boundaries.
|
|
2
|
+
|
|
3
|
+
The Phase 0 driver implements dsh's lifecycle verbatim **except tool
|
|
4
|
+
execution**, which lands with the pipeline in Phase 1 (P1-02). The order below
|
|
5
|
+
is the contract, and every stabilization feature in Phase 4 attaches to one of
|
|
6
|
+
these seams rather than to the loop itself (D12):
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
turn/start
|
|
10
|
+
├ inbox.claim → agent/inbox/claimed
|
|
11
|
+
├ system_prompt.assemble → system-prompt/assemble
|
|
12
|
+
├ agent/pre-step → reject | enter(messages)
|
|
13
|
+
│ reject → turn/end{blocked}
|
|
14
|
+
│ enter, empty, first step → turn/end{completed}
|
|
15
|
+
├ step/start
|
|
16
|
+
│ user/message* (the claimed batch, surface: append)
|
|
17
|
+
│ agent/request → LlmCallConfig
|
|
18
|
+
│ request/header (appended only when it changed — A12)
|
|
19
|
+
│ request/context (appended only when the route changed)
|
|
20
|
+
│ llm/stream → assistant/chunk* → assistant/message
|
|
21
|
+
│ agent/request-error → retry | None
|
|
22
|
+
├ step/end
|
|
23
|
+
├ agent/turn-stopping (a listener objects by steering)
|
|
24
|
+
turn/end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Two rules are easy to lose in a port and expensive to lose in production:
|
|
28
|
+
|
|
29
|
+
* every request's `messages` is `session.derive_messages()` — never a
|
|
30
|
+
separately-maintained array (invariant I3, asserted by P0-14);
|
|
31
|
+
* `max-tokens` is **sticky** for the turn: a later completed step must not
|
|
32
|
+
downgrade the outcome, or a truncated answer is reported as a clean one.
|
|
33
|
+
|
|
34
|
+
The driver runs inside the scope `ctx.agents` created for it. Services resolve
|
|
35
|
+
through that scope most-specific-first, so `self.ctx.llm` is the global adapter
|
|
36
|
+
seam unless something shadowed it for this agent alone.
|
|
37
|
+
|
|
38
|
+
@module ph.agent_loop.driver
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
from collections.abc import Sequence
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
import anyio
|
|
49
|
+
|
|
50
|
+
from ..agent.inbox import Inbox, InboxNotifications, InboxTarget
|
|
51
|
+
from ..agent.types import (
|
|
52
|
+
AgentCancelCause,
|
|
53
|
+
AgentOptions,
|
|
54
|
+
AgentStatus,
|
|
55
|
+
PreStepDecision,
|
|
56
|
+
PreStepRequest,
|
|
57
|
+
RequestErrorAction,
|
|
58
|
+
RequestFailure,
|
|
59
|
+
RequestProposal,
|
|
60
|
+
TurnEndReason,
|
|
61
|
+
)
|
|
62
|
+
from ..cancel import Canceled, CancelToken
|
|
63
|
+
from ..cordis import Context, settled, settled_or_none
|
|
64
|
+
from ..json import as_int
|
|
65
|
+
from ..keys import LLM, SYSTEM_PROMPT
|
|
66
|
+
from ..llm.adapter import LlmError
|
|
67
|
+
from ..llm.assembler import BlockAssembler
|
|
68
|
+
from ..llm.types import (
|
|
69
|
+
ContentBlock,
|
|
70
|
+
FinishReason,
|
|
71
|
+
GenerateOptions,
|
|
72
|
+
LlmCallConfig,
|
|
73
|
+
LlmFailure,
|
|
74
|
+
Message,
|
|
75
|
+
PluginSource,
|
|
76
|
+
TokenUsage,
|
|
77
|
+
ToolCallBlock,
|
|
78
|
+
create_assistant_message,
|
|
79
|
+
create_user_message,
|
|
80
|
+
)
|
|
81
|
+
from ..session import Session, SurfaceIntent
|
|
82
|
+
from ..session.request_header import EpochHeader, RequestContext, canonical_header, header_equals
|
|
83
|
+
from ..system_prompt.assembly import (
|
|
84
|
+
PromptAssembly,
|
|
85
|
+
join_context_sections,
|
|
86
|
+
render_context_sections,
|
|
87
|
+
render_prompt,
|
|
88
|
+
)
|
|
89
|
+
from ..tools.batch import execute_tool_calls
|
|
90
|
+
from ..tools.errors import error_info
|
|
91
|
+
|
|
92
|
+
__all__ = ["AgentCanceled", "ReactLoopAgent"]
|
|
93
|
+
|
|
94
|
+
log = logging.getLogger("ph.agent_loop")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AgentCanceled(Exception):
|
|
98
|
+
"""The active driver was canceled; carries the cause for `turn/end`."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, cause: AgentCancelCause) -> None:
|
|
101
|
+
super().__init__(f"agent canceled: {cause.kind}")
|
|
102
|
+
self.cause = cause
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class _Phase:
|
|
107
|
+
kind: str = "idle"
|
|
108
|
+
turn: int = 0
|
|
109
|
+
step: int = 0
|
|
110
|
+
canceled: AgentCancelCause | None = None
|
|
111
|
+
token: CancelToken = field(default_factory=CancelToken)
|
|
112
|
+
"""The live cancellation view handed to tool calls.
|
|
113
|
+
|
|
114
|
+
A flag the loop checks between awaits is not enough once a tool body can run
|
|
115
|
+
for minutes: the body needs to observe cancellation itself, and the pipeline
|
|
116
|
+
needs to tell "aborted before dispatch" from "aborted" to know whether the
|
|
117
|
+
call had an effect."""
|
|
118
|
+
|
|
119
|
+
def begin_turn(self) -> None:
|
|
120
|
+
"""Fresh per-turn state: a new token, so an old cancellation cannot leak
|
|
121
|
+
into the next turn's tool calls."""
|
|
122
|
+
self.step = 0
|
|
123
|
+
self.canceled = None
|
|
124
|
+
self.token = CancelToken()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class _PreparedStep:
|
|
129
|
+
kind: str
|
|
130
|
+
messages: tuple[Message, ...] = ()
|
|
131
|
+
assembly: PromptAssembly | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ReactLoopAgent:
|
|
135
|
+
"""The default agent driver."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
ctx: Context,
|
|
140
|
+
session: Session,
|
|
141
|
+
options: AgentOptions,
|
|
142
|
+
*,
|
|
143
|
+
max_parallel_tool_calls: int = 10,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.ctx = ctx
|
|
146
|
+
self.session = session
|
|
147
|
+
self.options = options
|
|
148
|
+
self.id = session.id
|
|
149
|
+
self.max_parallel_tool_calls = max_parallel_tool_calls
|
|
150
|
+
self._phase = _Phase(turn=_last_turn_of(session))
|
|
151
|
+
self._request_header_logged = False
|
|
152
|
+
self._context_snapshot: str | None = None
|
|
153
|
+
self._idle = anyio.Event()
|
|
154
|
+
self._idle.set()
|
|
155
|
+
self.inbox = Inbox(
|
|
156
|
+
session,
|
|
157
|
+
InboxNotifications(
|
|
158
|
+
inserted=lambda message: ctx.emit("agent/inbox/inserted", self, message),
|
|
159
|
+
discarded=lambda message: ctx.emit("agent/inbox/discarded", self, message),
|
|
160
|
+
claimed=lambda message, turn: ctx.emit("agent/inbox/claimed", self, message, turn),
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# -------------------------------------------------------------- identity --
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return f"<ReactLoopAgent {self.id} {self.status}>"
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def status(self) -> AgentStatus:
|
|
171
|
+
return "idle" if self._phase.kind == "idle" else "running"
|
|
172
|
+
|
|
173
|
+
def _set_phase(self, kind: str) -> None:
|
|
174
|
+
previous = self.status
|
|
175
|
+
self._phase.kind = kind
|
|
176
|
+
if self.status != previous:
|
|
177
|
+
self.ctx.emit("agent/status", self, self.status)
|
|
178
|
+
|
|
179
|
+
# ----------------------------------------------------------------- inbox --
|
|
180
|
+
|
|
181
|
+
def send(self, message: Message, target: InboxTarget, wakeup: bool) -> None:
|
|
182
|
+
# Waking input cannot join an aborted activity, so it starts the next
|
|
183
|
+
# turn. Classified BEFORE the insertion, so a reentrant cancel from a
|
|
184
|
+
# splice observer cannot reclassify it.
|
|
185
|
+
waking_after_abort = (
|
|
186
|
+
wakeup and self._phase.kind != "idle" and self._phase.canceled is not None
|
|
187
|
+
)
|
|
188
|
+
self.inbox.append("next-turn" if waking_after_abort else target, message)
|
|
189
|
+
|
|
190
|
+
def followup(self, message: Message) -> None:
|
|
191
|
+
"""Deliver at the next turn boundary and wake the agent."""
|
|
192
|
+
self.send(message, "next-turn", True)
|
|
193
|
+
|
|
194
|
+
def steer(self, message: Message) -> None:
|
|
195
|
+
"""Deliver at the next step boundary and wake the agent."""
|
|
196
|
+
self.send(message, "next-step", True)
|
|
197
|
+
|
|
198
|
+
def inject(self, message: Message) -> None:
|
|
199
|
+
"""Deliver at the next step boundary without waking — it waits."""
|
|
200
|
+
self.send(message, "next-step", False)
|
|
201
|
+
|
|
202
|
+
def interject(self, message: Message) -> None:
|
|
203
|
+
"""Deliver as soon as this loop will take it: mid-turn if one is running.
|
|
204
|
+
|
|
205
|
+
The verb for *"also this"* from outside — a person typing while the agent
|
|
206
|
+
works. A running turn takes it at the next step, so the answer does not
|
|
207
|
+
wait for whatever the agent is in the middle of; an idle one has no turn
|
|
208
|
+
to join, and a prompt to an idle agent means a new one.
|
|
209
|
+
|
|
210
|
+
**Decided here rather than by the caller**, because the phase is this
|
|
211
|
+
object's and a caller reading `status` and then choosing a verb is
|
|
212
|
+
branching on a value that can change between the two. It also gives every
|
|
213
|
+
front end the same reach without each re-deriving it — and the reach a
|
|
214
|
+
child already had, since a child's message is delivered by `steer`.
|
|
215
|
+
"""
|
|
216
|
+
self.send(message, "next-step" if self._phase.kind != "idle" else "next-turn", True)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def signal(self) -> CancelToken:
|
|
220
|
+
"""The live cancellation view — the same token `cancel` below trips."""
|
|
221
|
+
return self._phase.token
|
|
222
|
+
|
|
223
|
+
def cancel(self, cause: AgentCancelCause, *, keep_inbox: bool = False) -> None:
|
|
224
|
+
if not keep_inbox:
|
|
225
|
+
self.inbox.clear()
|
|
226
|
+
self._phase.canceled = cause
|
|
227
|
+
self._phase.token.cancel(cause.kind)
|
|
228
|
+
|
|
229
|
+
def _throw_if_canceled(self) -> None:
|
|
230
|
+
if self._phase.canceled is not None:
|
|
231
|
+
raise AgentCanceled(self._phase.canceled)
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------ run --
|
|
234
|
+
|
|
235
|
+
async def when_idle(self) -> None:
|
|
236
|
+
await self._idle.wait()
|
|
237
|
+
|
|
238
|
+
async def run(self) -> None:
|
|
239
|
+
"""Drive turns until the inbox is empty.
|
|
240
|
+
|
|
241
|
+
The public entry point; a daemon or the CLI calls it after queueing
|
|
242
|
+
input. Failures are reported at their live boundary (`agent/error`) and
|
|
243
|
+
contained here, because a driver that propagated would take the process
|
|
244
|
+
down with one bad turn.
|
|
245
|
+
"""
|
|
246
|
+
if self._phase.kind != "idle":
|
|
247
|
+
raise RuntimeError(f'agent "{self.id}" already has active work')
|
|
248
|
+
self._idle = anyio.Event()
|
|
249
|
+
self._set_phase("running")
|
|
250
|
+
self._phase.begin_turn()
|
|
251
|
+
try:
|
|
252
|
+
while await self._turn():
|
|
253
|
+
pass
|
|
254
|
+
except (AgentCanceled, Canceled):
|
|
255
|
+
pass
|
|
256
|
+
except Exception:
|
|
257
|
+
log.debug("ph.agent_loop: driver contained a failure", exc_info=True)
|
|
258
|
+
finally:
|
|
259
|
+
self._set_phase("idle")
|
|
260
|
+
self._idle.set()
|
|
261
|
+
|
|
262
|
+
async def prompt(self, text: str) -> None:
|
|
263
|
+
"""Queue one human prompt and drive the loop to idle."""
|
|
264
|
+
self.followup(
|
|
265
|
+
create_user_message(content=[{"type": "text", "text": text}], source={"kind": "user"})
|
|
266
|
+
)
|
|
267
|
+
await self.run()
|
|
268
|
+
|
|
269
|
+
async def dispose(self) -> None:
|
|
270
|
+
self.cancel(AgentCancelCause(kind="disposed"))
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------- phases --
|
|
273
|
+
|
|
274
|
+
def _report(self, error: BaseException) -> None:
|
|
275
|
+
self.ctx.emit("agent/error", self, self._phase.turn, self._phase.step, error)
|
|
276
|
+
|
|
277
|
+
async def _pre_step(self, target: InboxTarget, turn: int, step: int) -> _PreparedStep:
|
|
278
|
+
self._throw_if_canceled()
|
|
279
|
+
claimed = self.inbox.claim(target, turn)
|
|
280
|
+
assembly = await self.ctx.require(SYSTEM_PROMPT).assemble(self.ctx, agent=self)
|
|
281
|
+
self._throw_if_canceled()
|
|
282
|
+
context_message = self._project_context(assembly)
|
|
283
|
+
messages = (*claimed, context_message) if context_message is not None else tuple(claimed)
|
|
284
|
+
|
|
285
|
+
async def inner(request: PreStepRequest) -> PreStepDecision:
|
|
286
|
+
return PreStepDecision(kind="enter", messages=request.messages)
|
|
287
|
+
|
|
288
|
+
request = PreStepRequest(
|
|
289
|
+
agent=self, session=self.session, messages=messages, turn=turn, step=step
|
|
290
|
+
)
|
|
291
|
+
answered = await self.ctx.waterfall("agent/pre-step", request, inner=inner)
|
|
292
|
+
self._throw_if_canceled()
|
|
293
|
+
decision = settled("agent/pre-step", answered, PreStepDecision)
|
|
294
|
+
if decision.kind == "reject":
|
|
295
|
+
return _PreparedStep(kind="reject")
|
|
296
|
+
return _PreparedStep(kind="enter", messages=decision.messages, assembly=assembly)
|
|
297
|
+
|
|
298
|
+
def _project_context(self, assembly: PromptAssembly) -> Message | None:
|
|
299
|
+
"""Materialize `context()` providers, but only when the text changed.
|
|
300
|
+
|
|
301
|
+
This is the whole reason `context()` exists separately from `section`:
|
|
302
|
+
re-sending unchanged context on every step would invalidate the cached
|
|
303
|
+
prefix each turn (A12).
|
|
304
|
+
"""
|
|
305
|
+
sections = render_context_sections(assembly)
|
|
306
|
+
if not sections:
|
|
307
|
+
return None
|
|
308
|
+
text = join_context_sections(sections)
|
|
309
|
+
if text == self._context_snapshot:
|
|
310
|
+
return None
|
|
311
|
+
self._context_snapshot = text
|
|
312
|
+
return create_user_message(
|
|
313
|
+
content=[{"type": "text", "text": text}],
|
|
314
|
+
source=PluginSource(
|
|
315
|
+
plugin="ph.system-prompt", form="snapshot", sections=list(sections)
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def _turn(self) -> bool:
|
|
320
|
+
phase = self._phase
|
|
321
|
+
self._throw_if_canceled()
|
|
322
|
+
turn = phase.turn + 1
|
|
323
|
+
self.session.append("turn/start", {"turn": turn})
|
|
324
|
+
phase.turn = turn
|
|
325
|
+
turn_ends: TurnEndReason | None = None
|
|
326
|
+
target: InboxTarget = "next-turn"
|
|
327
|
+
try:
|
|
328
|
+
while True:
|
|
329
|
+
self._throw_if_canceled()
|
|
330
|
+
step = phase.step + 1
|
|
331
|
+
decision = await self._pre_step(target, turn, step)
|
|
332
|
+
if decision.kind == "reject":
|
|
333
|
+
turn_ends = TurnEndReason(kind="blocked")
|
|
334
|
+
return False
|
|
335
|
+
if turn_ends is not None and not decision.messages:
|
|
336
|
+
break
|
|
337
|
+
# A removed waking message, or an `enter` rewritten to empty,
|
|
338
|
+
# still owns the turn boundary it opened — it just spends no
|
|
339
|
+
# model call.
|
|
340
|
+
if phase.step == 0 and not decision.messages:
|
|
341
|
+
turn_ends = TurnEndReason(kind="completed")
|
|
342
|
+
return False
|
|
343
|
+
self._throw_if_canceled()
|
|
344
|
+
self.session.append("step/start", {"turn": turn, "step": step})
|
|
345
|
+
phase.step = step
|
|
346
|
+
try:
|
|
347
|
+
for message in decision.messages:
|
|
348
|
+
self.session.append(
|
|
349
|
+
"user/message", message.to_wire(), SurfaceIntent("append")
|
|
350
|
+
)
|
|
351
|
+
assert decision.assembly is not None
|
|
352
|
+
step_end = await self._step(decision.assembly)
|
|
353
|
+
# max-tokens stays sticky: a later completed step must not
|
|
354
|
+
# downgrade the turn outcome.
|
|
355
|
+
if turn_ends is None or turn_ends.kind != "max-tokens":
|
|
356
|
+
turn_ends = step_end
|
|
357
|
+
finally:
|
|
358
|
+
self.session.append("step/end", {"turn": turn, "step": step})
|
|
359
|
+
self._throw_if_canceled()
|
|
360
|
+
if turn_ends is not None and not self.inbox.next_step:
|
|
361
|
+
await self.ctx.serial("agent/turn-stopping", self, turn)
|
|
362
|
+
self._throw_if_canceled()
|
|
363
|
+
if turn_ends is not None and not self.inbox.next_step:
|
|
364
|
+
break
|
|
365
|
+
target = "next-step"
|
|
366
|
+
except (AgentCanceled, Canceled) as canceled:
|
|
367
|
+
cause = (
|
|
368
|
+
canceled.cause
|
|
369
|
+
if isinstance(canceled, AgentCanceled)
|
|
370
|
+
else self._phase.canceled or AgentCancelCause(kind="user")
|
|
371
|
+
)
|
|
372
|
+
turn_ends = TurnEndReason(kind="aborted", reason=cause)
|
|
373
|
+
raise
|
|
374
|
+
except Exception as error:
|
|
375
|
+
# A harness error's own code rather than `UNKNOWN`, because that is
|
|
376
|
+
# what `HarnessError` is for: *"a failure's routing matters as much as
|
|
377
|
+
# its message — retry policy, the sandbox layer and replay all branch
|
|
378
|
+
# on the code"*. Flattening every one of them here made `turn/end`
|
|
379
|
+
# unable to say which failure it was, in the record a client reads to
|
|
380
|
+
# decide whether retrying could possibly help.
|
|
381
|
+
failure = error.failure if isinstance(error, LlmError) else _as_failure(error)
|
|
382
|
+
turn_ends = TurnEndReason(kind="error", error=failure)
|
|
383
|
+
self._report(error)
|
|
384
|
+
raise
|
|
385
|
+
finally:
|
|
386
|
+
self.session.append(
|
|
387
|
+
"turn/end",
|
|
388
|
+
{"turn": turn, "reason": (turn_ends or TurnEndReason(kind="completed")).to_wire()},
|
|
389
|
+
)
|
|
390
|
+
if not self.inbox.has_pending:
|
|
391
|
+
return False
|
|
392
|
+
phase.begin_turn()
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
async def _step(self, assembly: PromptAssembly) -> TurnEndReason | None:
|
|
396
|
+
phase = self._phase
|
|
397
|
+
turn, step = phase.turn, phase.step
|
|
398
|
+
self._throw_if_canceled()
|
|
399
|
+
system = render_prompt(assembly)
|
|
400
|
+
|
|
401
|
+
while True:
|
|
402
|
+
request = await self._build_request(turn, step, assembly, system)
|
|
403
|
+
assembler = BlockAssembler()
|
|
404
|
+
chunk_seqs: list[int] = []
|
|
405
|
+
try:
|
|
406
|
+
stream = await self.ctx.require(LLM).stream(request)
|
|
407
|
+
self._throw_if_canceled()
|
|
408
|
+
async for chunk in stream:
|
|
409
|
+
self._throw_if_canceled()
|
|
410
|
+
# Raw chunks are logged before assembly, so the log carries
|
|
411
|
+
# token-level replay fidelity even for a stream that later
|
|
412
|
+
# fails.
|
|
413
|
+
chunk_seqs.append(
|
|
414
|
+
self.session.append(
|
|
415
|
+
"assistant/chunk",
|
|
416
|
+
{"turn": turn, "step": step, "chunk": chunk.to_wire()},
|
|
417
|
+
).seq
|
|
418
|
+
)
|
|
419
|
+
assembler.push(chunk)
|
|
420
|
+
self._throw_if_canceled()
|
|
421
|
+
except (AgentCanceled, Canceled):
|
|
422
|
+
content = assembler.interrupted_blocks()
|
|
423
|
+
if content:
|
|
424
|
+
# An interrupted turn still finalizes what the user saw:
|
|
425
|
+
# dropping it would leave the transcript claiming the model
|
|
426
|
+
# said nothing.
|
|
427
|
+
self._append_assistant_message(
|
|
428
|
+
turn, step, request, content, chunk_seqs, assembler.usage, interrupted=True
|
|
429
|
+
)
|
|
430
|
+
raise
|
|
431
|
+
|
|
432
|
+
finish = assembler.finish
|
|
433
|
+
if finish.kind in ("error", "aborted"):
|
|
434
|
+
action = await self._request_error(turn, step, request, finish)
|
|
435
|
+
if action is None or action.kind != "retry":
|
|
436
|
+
failure = finish.failure or LlmFailure(
|
|
437
|
+
message="model request failed", code="UNKNOWN"
|
|
438
|
+
)
|
|
439
|
+
raise LlmError(failure.message, failure.code, failure)
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
blocks = assembler.blocks()
|
|
443
|
+
self._append_assistant_message(turn, step, request, blocks, chunk_seqs, assembler.usage)
|
|
444
|
+
if finish.kind == "max-tokens":
|
|
445
|
+
# Sticky, and checked before dispatch: max-tokens already
|
|
446
|
+
# dropped any truncated tool call, so there is nothing to run.
|
|
447
|
+
return TurnEndReason(kind="max-tokens")
|
|
448
|
+
tool_calls = [block for block in blocks if isinstance(block, ToolCallBlock)]
|
|
449
|
+
if not tool_calls:
|
|
450
|
+
return TurnEndReason(kind="completed")
|
|
451
|
+
return await self._dispatch_tools(turn, step, tool_calls)
|
|
452
|
+
|
|
453
|
+
async def _dispatch_tools(
|
|
454
|
+
self, turn: int, step: int, tool_calls: list[ToolCallBlock]
|
|
455
|
+
) -> TurnEndReason | None:
|
|
456
|
+
"""Run the step's tool batch; `None` continues the turn with another step.
|
|
457
|
+
|
|
458
|
+
Result context is spliced into the next-step inbox rather than appended
|
|
459
|
+
here, so it lands *after* every `tool/result` and call/result adjacency
|
|
460
|
+
survives.
|
|
461
|
+
"""
|
|
462
|
+
outcome = await execute_tool_calls(
|
|
463
|
+
self.ctx,
|
|
464
|
+
self,
|
|
465
|
+
turn,
|
|
466
|
+
step,
|
|
467
|
+
tool_calls,
|
|
468
|
+
self._phase.token,
|
|
469
|
+
lambda context: self.inbox.append("next-step", context),
|
|
470
|
+
max_parallel=self.max_parallel_tool_calls,
|
|
471
|
+
)
|
|
472
|
+
if outcome.aborted:
|
|
473
|
+
self._throw_if_canceled()
|
|
474
|
+
raise Canceled("tool batch aborted")
|
|
475
|
+
return TurnEndReason(kind="completed") if outcome.concluded else None
|
|
476
|
+
|
|
477
|
+
async def _request_error(
|
|
478
|
+
self, turn: int, step: int, request: GenerateOptions, finish: FinishReason
|
|
479
|
+
) -> RequestErrorAction | None:
|
|
480
|
+
async def inner(request_failure: RequestFailure) -> RequestErrorAction | None:
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
failure = RequestFailure(
|
|
484
|
+
agent=self,
|
|
485
|
+
turn=turn,
|
|
486
|
+
step=step,
|
|
487
|
+
provider=request.provider,
|
|
488
|
+
failure=finish.failure or LlmFailure(message="model request failed", code="UNKNOWN"),
|
|
489
|
+
)
|
|
490
|
+
action = await self.ctx.waterfall("agent/request-error", failure, inner=inner)
|
|
491
|
+
self._throw_if_canceled()
|
|
492
|
+
return settled_or_none("agent/request-error", action, RequestErrorAction)
|
|
493
|
+
|
|
494
|
+
def _append_assistant_message(
|
|
495
|
+
self,
|
|
496
|
+
turn: int,
|
|
497
|
+
step: int,
|
|
498
|
+
request: GenerateOptions,
|
|
499
|
+
content: Sequence[ContentBlock],
|
|
500
|
+
chunk_seqs: list[int],
|
|
501
|
+
usage: TokenUsage | None,
|
|
502
|
+
*,
|
|
503
|
+
interrupted: bool = False,
|
|
504
|
+
) -> None:
|
|
505
|
+
message = create_assistant_message(
|
|
506
|
+
content=content, provider=request.provider, model=request.model
|
|
507
|
+
)
|
|
508
|
+
data: dict[str, Any] = {"turn": turn, "step": step, "message": message.to_wire()}
|
|
509
|
+
if usage is not None:
|
|
510
|
+
data["usage"] = usage.to_wire()
|
|
511
|
+
if interrupted:
|
|
512
|
+
data["interrupted"] = True
|
|
513
|
+
self.session.append("assistant/message", data, SurfaceIntent("append", tuple(chunk_seqs)))
|
|
514
|
+
|
|
515
|
+
async def _build_request(
|
|
516
|
+
self, turn: int, step: int, assembly: PromptAssembly, system: str
|
|
517
|
+
) -> GenerateOptions:
|
|
518
|
+
"""Compose one frozen request and log its header when it changed.
|
|
519
|
+
|
|
520
|
+
The message list is `session.derive_messages()` and nothing else — the
|
|
521
|
+
invariant plugin will refuse the request otherwise.
|
|
522
|
+
"""
|
|
523
|
+
session = self.session
|
|
524
|
+
persisted = session.request_header()
|
|
525
|
+
seed = (
|
|
526
|
+
_request_proposal(persisted)
|
|
527
|
+
if self._request_header_logged and persisted is not None
|
|
528
|
+
else self.options.seed_config()
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
async def inner(proposal: RequestProposal) -> LlmCallConfig:
|
|
532
|
+
return proposal.config
|
|
533
|
+
|
|
534
|
+
proposal = RequestProposal(agent=self, turn=turn, step=step, config=seed)
|
|
535
|
+
answered = await self.ctx.waterfall("agent/request", proposal, inner=inner)
|
|
536
|
+
self._throw_if_canceled()
|
|
537
|
+
proposed = settled("agent/request", answered, LlmCallConfig)
|
|
538
|
+
if not proposed.provider or not proposed.model:
|
|
539
|
+
raise ValueError(
|
|
540
|
+
f'agent "{self.id}" has no provider/model: set AgentOptions.provider '
|
|
541
|
+
"and AgentOptions.model, or supply both via the agent/request waterfall"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
header = canonical_header(
|
|
545
|
+
EpochHeader(config=proposed, system=system or None, tools=list(assembly.tools) or None)
|
|
546
|
+
)
|
|
547
|
+
baseline = session.request_header()
|
|
548
|
+
if not self._request_header_logged:
|
|
549
|
+
session.append(
|
|
550
|
+
"request/header",
|
|
551
|
+
{"header": header.to_wire(), "reason": "initial" if baseline is None else "resume"},
|
|
552
|
+
)
|
|
553
|
+
self._request_header_logged = True
|
|
554
|
+
elif baseline is None or not header_equals(baseline, header):
|
|
555
|
+
session.append("request/header", {"header": header.to_wire(), "reason": "change"})
|
|
556
|
+
|
|
557
|
+
resolved = self.ctx.require(LLM).resolve_model(proposed.provider, proposed.model)
|
|
558
|
+
request_context = RequestContext(
|
|
559
|
+
provider=proposed.provider, model=proposed.model, context_window=resolved.context_window
|
|
560
|
+
)
|
|
561
|
+
if session.request_context() != request_context:
|
|
562
|
+
session.append("request/context", request_context.to_wire())
|
|
563
|
+
self._throw_if_canceled()
|
|
564
|
+
|
|
565
|
+
return GenerateOptions(
|
|
566
|
+
provider=header.config.provider,
|
|
567
|
+
model=header.config.model,
|
|
568
|
+
messages=session.derive_messages(),
|
|
569
|
+
system=header.system,
|
|
570
|
+
tools=tuple(header.tools or ()),
|
|
571
|
+
reasoning_effort=header.config.reasoning_effort,
|
|
572
|
+
temperature=header.config.temperature,
|
|
573
|
+
max_tokens=header.config.max_tokens,
|
|
574
|
+
stop=tuple(header.config.stop or ()),
|
|
575
|
+
session_id=session.id,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _request_proposal(header: EpochHeader) -> LlmCallConfig:
|
|
580
|
+
"""Strip adapter-materialized values before plugins propose the next config.
|
|
581
|
+
|
|
582
|
+
A default the adapter chose is re-resolved per step against the exact model;
|
|
583
|
+
freezing one into the conversation would outlive the route that produced it.
|
|
584
|
+
"""
|
|
585
|
+
defaults = header.adapter_defaults
|
|
586
|
+
if defaults is None:
|
|
587
|
+
return header.config
|
|
588
|
+
data = header.config.model_dump(by_alias=True, exclude_none=True)
|
|
589
|
+
if defaults.reasoning_effort is True:
|
|
590
|
+
data.pop("reasoningEffort", None)
|
|
591
|
+
if defaults.max_tokens is True:
|
|
592
|
+
data.pop("maxTokens", None)
|
|
593
|
+
return LlmCallConfig.model_validate(data)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _last_turn_of(session: Session) -> int:
|
|
597
|
+
event = session.last_event_of("turn/start")
|
|
598
|
+
return as_int(event.data.get("turn")) if event is not None else 0
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _error_chain(error: BaseException) -> str:
|
|
602
|
+
parts: list[str] = []
|
|
603
|
+
seen: set[int] = set()
|
|
604
|
+
current: BaseException | None = error
|
|
605
|
+
while current is not None and id(current) not in seen:
|
|
606
|
+
seen.add(id(current))
|
|
607
|
+
parts.append(str(current) or type(current).__name__)
|
|
608
|
+
current = current.__cause__ or current.__context__
|
|
609
|
+
return ": ".join(parts)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _as_failure(error: Exception) -> LlmFailure:
|
|
613
|
+
"""A raised error as a turn's `reason.error`, keeping its code if it has one.
|
|
614
|
+
|
|
615
|
+
A harness error's own code rather than `UNKNOWN`, because that is what
|
|
616
|
+
`HarnessError` is for: *"a failure's routing matters as much as its message —
|
|
617
|
+
retry policy, the sandbox layer and replay all branch on the code"*. Flattening
|
|
618
|
+
every one of them left `turn/end` unable to say which failure it was, in the
|
|
619
|
+
record a client reads to decide whether retrying could possibly help.
|
|
620
|
+
"""
|
|
621
|
+
coded = error_info(error)
|
|
622
|
+
return LlmFailure(
|
|
623
|
+
message=_error_chain(error), code="UNKNOWN" if coded is None else coded["code"]
|
|
624
|
+
)
|