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
@@ -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
+ )