ph-core 0.2.0__tar.gz → 0.4.0__tar.gz

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 (253) hide show
  1. {ph_core-0.2.0 → ph_core-0.4.0}/PKG-INFO +3 -3
  2. {ph_core-0.2.0 → ph_core-0.4.0}/README.md +2 -2
  3. {ph_core-0.2.0 → ph_core-0.4.0}/pyproject.toml +1 -1
  4. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/__init__.py +1 -1
  5. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/inbox.py +4 -1
  6. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/registry.py +7 -1
  7. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/types.py +38 -0
  8. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/driver.py +164 -57
  9. ph_core-0.4.0/src/ph/cancel.py +208 -0
  10. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/autonomous.py +13 -2
  11. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/revert.py +2 -15
  12. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/sandbox.py +2 -2
  13. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/__init__.py +6 -0
  14. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/context.py +423 -48
  15. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/events.py +13 -0
  16. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/key.py +6 -0
  17. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/loader.py +86 -30
  18. ph_core-0.4.0/src/ph/indexable.py +279 -0
  19. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/json.py +24 -3
  20. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/keys.py +3 -0
  21. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/lingering.py +2 -1
  22. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/__init__.py +2 -1
  23. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/adapter.py +50 -6
  24. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/assembler.py +81 -6
  25. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/media.py +6 -1
  26. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/replay.py +36 -10
  27. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/retry.py +38 -23
  28. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/structured.py +61 -14
  29. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/types.py +58 -0
  30. ph_core-0.4.0/src/ph/locks.py +80 -0
  31. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/orphans.py +87 -13
  32. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/paths.py +89 -0
  33. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/__init__.py +12 -3
  34. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/checkpoint_policy.py +21 -7
  35. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/families.py +9 -1
  36. ph_core-0.4.0/src/ph/persistence/jsonl.py +880 -0
  37. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/lease.py +41 -14
  38. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/lineage.py +15 -3
  39. ph_core-0.4.0/src/ph/persistence/opening.py +106 -0
  40. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/protocol.py +112 -18
  41. ph_core-0.4.0/src/ph/persistence/repair.py +426 -0
  42. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/persistence/turso.py +77 -69
  43. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/resources.py +24 -0
  44. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/approval.py +94 -93
  45. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/attachments.py +11 -10
  46. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/changes.py +4 -6
  47. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/code_runtime_stub.py +1 -2
  48. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/commands.py +18 -2
  49. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/credentials.py +88 -2
  50. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/fs.py +298 -39
  51. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/goals.py +70 -17
  52. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/jobs.py +15 -0
  53. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/permission_presets.py +4 -1
  54. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox.py +120 -14
  55. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_local.py +51 -6
  56. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/schedule.py +58 -11
  57. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/schedule_index.py +124 -20
  58. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/settings.py +2 -2
  59. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/shell.py +22 -1
  60. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/skills.py +62 -8
  61. ph_core-0.4.0/src/ph/seams/spill.py +478 -0
  62. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/subagents.py +315 -59
  63. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/subprocess.py +107 -19
  64. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/telemetry.py +26 -12
  65. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/telemetry_otel.py +9 -5
  66. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/token_meter.py +9 -4
  67. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/tui_screens.py +26 -1
  68. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/uploads.py +21 -19
  69. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/user_questions.py +108 -94
  70. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace.py +91 -13
  71. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_agentfs.py +21 -10
  72. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_git.py +172 -33
  73. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_jj.py +21 -7
  74. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/__init__.py +76 -2
  75. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/events.py +34 -3
  76. ph_core-0.4.0/src/ph/session/intents.py +366 -0
  77. ph_core-0.4.0/src/ph/session/journal.py +412 -0
  78. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/json.py +1 -1
  79. ph_core-0.4.0/src/ph/session/kinds.py +451 -0
  80. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/known_event_types.py +353 -10
  81. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/session.py +239 -39
  82. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/store.py +140 -11
  83. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/surface.py +36 -6
  84. ph_core-0.4.0/src/ph/session/writers.py +143 -0
  85. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/assembly.py +57 -2
  86. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/__init__.py +18 -0
  87. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/builders.py +182 -11
  88. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/git.py +1 -2
  89. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/jj.py +2 -4
  90. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/text.py +16 -0
  91. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/__init__.py +29 -0
  92. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/batch.py +76 -7
  93. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/ask_user.py +72 -7
  94. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/bash_tool.py +18 -1
  95. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/fs_tools.py +65 -6
  96. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/subagent_task.py +50 -23
  97. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/code_mode.py +92 -12
  98. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/definition.py +174 -2
  99. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/errors.py +65 -0
  100. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/json_schema.py +22 -5
  101. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/registry.py +348 -19
  102. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/timeout.py +14 -1
  103. ph_core-0.4.0/tests/test_agent_loop.py +1008 -0
  104. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_ask_user.py +119 -12
  105. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attachments.py +36 -0
  106. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attachments_gc.py +13 -2
  107. ph_core-0.4.0/tests/test_cancel.py +171 -0
  108. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_code_mode.py +173 -4
  109. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_context.py +254 -0
  110. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_dispatch.py +135 -0
  111. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_events.py +21 -0
  112. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_loader.py +198 -2
  113. ph_core-0.4.0/tests/test_credential_holds.py +154 -0
  114. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_derive.py +26 -18
  115. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fold_laws.py +32 -24
  116. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fork.py +40 -14
  117. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_fs.py +205 -0
  118. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_goals.py +76 -6
  119. ph_core-0.4.0/tests/test_indexable.py +182 -0
  120. ph_core-0.4.0/tests/test_intent_kinds.py +227 -0
  121. ph_core-0.4.0/tests/test_intents.py +424 -0
  122. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_invariants.py +5 -3
  123. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_keys.py +14 -0
  124. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_llm.py +154 -2
  125. ph_core-0.4.0/tests/test_locks.py +95 -0
  126. ph_core-0.4.0/tests/test_log_writers.py +394 -0
  127. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_loop_policies.py +141 -14
  128. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_orphans.py +99 -7
  129. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_paths.py +65 -0
  130. ph_core-0.4.0/tests/test_persistence.py +844 -0
  131. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_persistence_backends.py +212 -20
  132. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_prefix_stability.py +31 -2
  133. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_registration_ownership.py +22 -1
  134. ph_core-0.4.0/tests/test_repair.py +1048 -0
  135. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_allow.py +134 -5
  136. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_local.py +46 -1
  137. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_schedule.py +132 -7
  138. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_seams.py +357 -34
  139. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_session_admit.py +45 -5
  140. ph_core-0.4.0/tests/test_session_append.py +570 -0
  141. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_session_event.py +1 -0
  142. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_skills_progressive.py +52 -0
  143. ph_core-0.4.0/tests/test_spill_sweep.py +303 -0
  144. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_structured.py +73 -1
  145. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_subagent_grant.py +171 -1
  146. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_subagent_task.py +118 -0
  147. ph_core-0.4.0/tests/test_subprocess.py +302 -0
  148. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_surface.py +51 -28
  149. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_telemetry.py +64 -0
  150. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_batch.py +98 -1
  151. ph_core-0.4.0/tests/test_tools_idempotency.py +240 -0
  152. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_pipeline.py +124 -0
  153. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_agentfs.py +44 -1
  154. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_checkpoint.py +40 -9
  155. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_git.py +199 -0
  156. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_jj.py +33 -0
  157. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_reconcile.py +88 -2
  158. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_retention.py +4 -3
  159. ph_core-0.2.0/src/ph/cancel.py +0 -96
  160. ph_core-0.2.0/src/ph/persistence/jsonl.py +0 -455
  161. ph_core-0.2.0/src/ph/persistence/repair.py +0 -260
  162. ph_core-0.2.0/src/ph/seams/spill.py +0 -263
  163. ph_core-0.2.0/tests/test_agent_loop.py +0 -425
  164. ph_core-0.2.0/tests/test_persistence.py +0 -345
  165. ph_core-0.2.0/tests/test_repair.py +0 -485
  166. ph_core-0.2.0/tests/test_session_append.py +0 -355
  167. ph_core-0.2.0/tests/test_spill_sweep.py +0 -150
  168. {ph_core-0.2.0 → ph_core-0.4.0}/.gitignore +0 -0
  169. {ph_core-0.2.0 → ph_core-0.4.0}/LICENSE +0 -0
  170. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent/__init__.py +0 -0
  171. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/__init__.py +0 -0
  172. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/agent_loop/invariant.py +0 -0
  173. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/__init__.py +0 -0
  174. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/base.yaml +0 -0
  175. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/bundles/headless.yaml +0 -0
  176. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/__init__.py +0 -0
  177. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/commands/workspaces.py +0 -0
  178. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/catalog.py +0 -0
  179. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/errors.py +0 -0
  180. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/cordis/plugin.py +0 -0
  181. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/documents.py +0 -0
  182. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/dimensions.py +0 -0
  183. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/llm/fake.py +0 -0
  184. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/py.typed +0 -0
  185. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/__init__.py +0 -0
  186. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_names.py +0 -0
  187. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_registry.py +0 -0
  188. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/_restriction.py +0 -0
  189. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/code_runtime.py +0 -0
  190. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/compaction.py +0 -0
  191. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/containment.py +0 -0
  192. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/diagnostics.py +0 -0
  193. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/invariants.py +0 -0
  194. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_allow.py +0 -0
  195. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/sandbox_egress.py +0 -0
  196. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/scope_invariant.py +0 -0
  197. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/skills_invariant.py +0 -0
  198. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/topology.py +0 -0
  199. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/tui_status.py +0 -0
  200. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_provision.py +0 -0
  201. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/seams/workspace_scratch.py +0 -0
  202. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/selectors.py +0 -0
  203. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/derive.py +0 -0
  204. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/folds.py +0 -0
  205. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/invariant.py +0 -0
  206. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/session/request_header.py +0 -0
  207. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/__init__.py +0 -0
  208. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/system_prompt/memory.py +0 -0
  209. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/anthropic_wire.py +0 -0
  210. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/diagnostics.py +0 -0
  211. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/folds.py +0 -0
  212. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/skills.py +0 -0
  213. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_sandbox.py +0 -0
  214. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_subagent.py +0 -0
  215. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/testing/stub_workspace.py +0 -0
  216. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/__init__.py +0 -0
  217. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/builtin/attach_tool.py +0 -0
  218. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/invariant.py +0 -0
  219. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/presentation.py +0 -0
  220. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/prompt.py +0 -0
  221. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/tools/sdk.py +0 -0
  222. {ph_core-0.2.0 → ph_core-0.4.0}/src/ph/wire.py +0 -0
  223. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_attach_tool.py +0 -0
  224. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_catalog.py +0 -0
  225. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_changes.py +0 -0
  226. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_commands_sandbox.py +0 -0
  227. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_commands_workspaces.py +0 -0
  228. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_containment.py +0 -0
  229. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_containment_ladder.py +0 -0
  230. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_cordis_plugin.py +0 -0
  231. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_diagnostics.py +0 -0
  232. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_dimensions.py +0 -0
  233. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_docs_seams.py +0 -0
  234. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_documents.py +0 -0
  235. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_exhaustive_dispatch.py +0 -0
  236. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_json.py +0 -0
  237. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_json_schema.py +0 -0
  238. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_layering.py +0 -0
  239. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_lingering.py +0 -0
  240. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_media_degrade.py +0 -0
  241. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_memory.py +0 -0
  242. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_resources.py +0 -0
  243. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sandbox_egress.py +0 -0
  244. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_sdk.py +0 -0
  245. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_selectors.py +0 -0
  246. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_telemetry_otel.py +0 -0
  247. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_text.py +0 -0
  248. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_tools_registry.py +0 -0
  249. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_wire_forms.py +0 -0
  250. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace.py +0 -0
  251. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_lifecycle.py +0 -0
  252. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_provision.py +0 -0
  253. {ph_core-0.2.0 → ph_core-0.4.0}/tests/test_workspace_scratch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ph-core
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: pH core: the cordis plugin subset, the session log, the LLM vocabulary, and the agent loop.
5
5
  Project-URL: Homepage, https://github.com/chastabor/pH
6
6
  Project-URL: Repository, https://github.com/chastabor/pH
@@ -153,11 +153,11 @@ phern config --row containment --profile tui # one row's knobs, defaults and wha
153
153
  | `containment` | `tier`, `childTier`, `strict` | **unset** — which is not `advisory`: unset means nobody chose, so a tier provider a profile layered is used |
154
154
  | `workspace-lifecycle` | `access`, `provision` | `write` |
155
155
  | `jobs-local` | `concurrency` (per job kind) | unset |
156
- | `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs` | `3`, `500`, `20000` |
156
+ | `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `countAllRetries` | `3`, `500`, `20000`, `false` — only `llm-retry`'s own retries spend its budget; `true` counts every row's, compaction's included |
157
157
  | `session-telemetry` | `enabled`, `path` | `enabled: false` in `ph-base` |
158
158
  | `subagent-presets` | `presets` | empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
159
159
  | `skills-progressive` | `paths` | **empty on purpose** — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
160
- | `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs` | `3`, `12`, `80000`, `1800000` |
160
+ | `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs`, `tokenSources` | `3`, `12`, `80000`, `1800000`, `[own]` — add `compaction` and `children` to charge summaries and subagents to `maxTokens` |
161
161
  | `subprocess-local` | `scrub`, `keep`, `maxOutputBytes` | `8388608` |
162
162
  | `tool-attach` | `maxBytes` | `33554432` |
163
163
 
@@ -119,11 +119,11 @@ phern config --row containment --profile tui # one row's knobs, defaults and wha
119
119
  | `containment` | `tier`, `childTier`, `strict` | **unset** — which is not `advisory`: unset means nobody chose, so a tier provider a profile layered is used |
120
120
  | `workspace-lifecycle` | `access`, `provision` | `write` |
121
121
  | `jobs-local` | `concurrency` (per job kind) | unset |
122
- | `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs` | `3`, `500`, `20000` |
122
+ | `llm-retry` | `maxAttempts`, `baseDelayMs`, `maxDelayMs`, `countAllRetries` | `3`, `500`, `20000`, `false` — only `llm-retry`'s own retries spend its budget; `true` counts every row's, compaction's included |
123
123
  | `session-telemetry` | `enabled`, `path` | `enabled: false` in `ph-base` |
124
124
  | `subagent-presets` | `presets` | empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
125
125
  | `skills-progressive` | `paths` | **empty on purpose** — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
126
- | `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs` | `3`, `12`, `80000`, `1800000` |
126
+ | `autonomous` | `maxContinuations`, `maxTurns`, `maxTokens`, `timeoutMs`, `tokenSources` | `3`, `12`, `80000`, `1800000`, `[own]` — add `compaction` and `children` to charge summaries and subagents to `maxTokens` |
127
127
  | `subprocess-local` | `scrub`, `keep`, `maxOutputBytes` | `8388608` |
128
128
  | `tool-attach` | `maxBytes` | `33554432` |
129
129
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ph-core"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "pH core: the cordis plugin subset, the session log, the LLM vocabulary, and the agent loop."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -6,4 +6,4 @@ and `plans/Implementation_Plan.md` for the work breakdown.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.4.0"
@@ -27,8 +27,11 @@ from pydantic import ConfigDict
27
27
  from ..json import JsonObject
28
28
  from ..llm.types import Message
29
29
  from ..session import Session
30
+ from ..session.writers import log_writer
30
31
  from ..wire import WireModel
31
32
 
33
+ _LOG = log_writer(__name__)
34
+
32
35
  __all__ = ["Inbox", "InboxNotifications", "InboxTarget"]
33
36
 
34
37
  InboxTarget: TypeAlias = Literal["next-turn", "next-step"]
@@ -163,7 +166,7 @@ class Inbox:
163
166
  # The durable event commits BEFORE the live projection mutates, so a
164
167
  # synchronous `session/event` observer sees the pre-splice lists and can
165
168
  # reconstruct exactly what was removed from the normalized coordinates.
166
- self._session.append("agent/inbox/spliced", splice.to_wire())
169
+ _LOG.append(self._session, "agent/inbox/spliced", splice.to_wire())
167
170
  removed = pending[start : start + delete_count]
168
171
  pending[start : start + delete_count] = inserted
169
172
  if discard_removed:
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
17
17
 
18
18
  from ..cordis import Context, events, plugin
19
19
  from ..keys import AGENT, AGENTS, SESSIONS
20
- from ..session import Session
20
+ from ..session import Session, session_written
21
21
  from .types import (
22
22
  AgentDriver,
23
23
  AgentHandle,
@@ -178,6 +178,12 @@ class AgentRegistry:
178
178
  # `_forget` rides the scope's own teardown, so the announcement and the
179
179
  # roster drop happen exactly once whichever way the scope goes.
180
180
  await agent.ctx.dispose()
181
+ # The teardown just appended to this agent's log — its workspace records
182
+ # `workspace/disposed` as it lets go — so written here, while the
183
+ # persistence row is still mounted, rather than whenever the mount
184
+ # unwinds. A mount's own unwind is `write_on_unwind`'s (F2).
185
+ if agent.session is not None:
186
+ await session_written(self.ctx, agent.session)
181
187
 
182
188
 
183
189
  @plugin("agent", inject=[SESSIONS])
@@ -12,6 +12,7 @@ a listener's signature *is* the contract: the limits and permissions plugins
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ from collections.abc import Mapping
15
16
  from dataclasses import dataclass
16
17
  from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias
17
18
 
@@ -170,6 +171,13 @@ class RequestProposal:
170
171
  """`agent/request`: the call config the loop proposes for one request."""
171
172
 
172
173
  agent: AgentHandle
174
+ session: Session
175
+ """Not optional here, for `PreStepRequest.session`'s reason (L5).
176
+
177
+ `input-offload` is the listener that had reached through the handle, and it
178
+ paid the same price `llm-retry` did: a `None` its only producer cannot
179
+ create, guarded by returning early — so the feature turned itself off rather
180
+ than failing."""
173
181
  turn: int
174
182
  step: int
175
183
  config: LlmCallConfig
@@ -180,10 +188,37 @@ class RequestFailure:
180
188
  """`agent/request-error`: a request that ended in `error` or `aborted`."""
181
189
 
182
190
  agent: AgentHandle
191
+ session: Session
192
+ """Not optional here, for `PreStepRequest.session`'s reason (L5).
193
+
194
+ Reaching through the handle instead made `llm-retry` guard a `None` its only
195
+ producer cannot create, and that guard does not fail loudly: it logs at
196
+ debug and declines the retry, so a route that had retries configured quietly
197
+ had none."""
183
198
  turn: int
184
199
  step: int
185
200
  provider: str
186
201
  failure: LlmFailure
202
+ retries_by: Mapping[str, int]
203
+ """How many times the loop has already retried this step, by the row that asked (G13).
204
+
205
+ Keyed by the plugin whose listener granted each retry, as
206
+ `Context.waterfall_attributed` reports it — `""` for a listener outside any
207
+ plugin. Counted by the driver, because every retry passes through it.
208
+ Handed to the policy rather than re-derived there, so a row budgets against
209
+ its own retries (`retries_by["llm-retry"]`) or against all of them
210
+ (`retries`) by choosing a number, not by folding a log only some rows write.
211
+
212
+ **Per step, per agent, by construction**: the count is a local in the
213
+ driver's retry loop, so two subagents at the same coordinates cannot share
214
+ it (I4) — the bug a count held on the listener had. Nothing is lost on a
215
+ resume either, because a resumed session starts a new turn and never
216
+ re-enters the step it was retrying."""
217
+
218
+ @property
219
+ def retries(self) -> int:
220
+ """Every retry this step has had, whichever row asked."""
221
+ return sum(self.retries_by.values())
187
222
 
188
223
 
189
224
  @dataclass(frozen=True, slots=True)
@@ -192,6 +227,9 @@ class RequestErrorAction:
192
227
 
193
228
  kind: Literal["retry"]
194
229
  delay_ms: int = 0
230
+ """How long the loop waits before the retry. The loop sleeps it, once the
231
+ waterfall has settled — never the row — so a listener that refuses the retry
232
+ costs no wait."""
195
233
 
196
234
 
197
235
  @dataclass(frozen=True, slots=True)
@@ -41,7 +41,8 @@ seam unless something shadowed it for this agent alone.
41
41
  from __future__ import annotations
42
42
 
43
43
  import logging
44
- from collections.abc import Sequence
44
+ from collections import Counter
45
+ from collections.abc import Mapping, Sequence
45
46
  from dataclasses import dataclass, field
46
47
  from typing import Any
47
48
 
@@ -64,7 +65,7 @@ from ..cordis import Context, settled, settled_or_none
64
65
  from ..json import as_int
65
66
  from ..keys import LLM, SYSTEM_PROMPT
66
67
  from ..llm.adapter import LlmError
67
- from ..llm.assembler import BlockAssembler
68
+ from ..llm.assembler import BlockAssembler, named_call
68
69
  from ..llm.types import (
69
70
  ContentBlock,
70
71
  FinishReason,
@@ -72,7 +73,6 @@ from ..llm.types import (
72
73
  LlmCallConfig,
73
74
  LlmFailure,
74
75
  Message,
75
- PluginSource,
76
76
  TokenUsage,
77
77
  ToolCallBlock,
78
78
  create_assistant_message,
@@ -80,15 +80,13 @@ from ..llm.types import (
80
80
  )
81
81
  from ..session import Session, SurfaceIntent
82
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
- )
83
+ from ..session.writers import log_writer
84
+ from ..system_prompt.assembly import PromptAssembly, context_message, render_prompt
89
85
  from ..tools.batch import execute_tool_calls
90
86
  from ..tools.errors import error_info
91
87
 
88
+ _LOG = log_writer(__name__)
89
+
92
90
  __all__ = ["AgentCanceled", "ReactLoopAgent"]
93
91
 
94
92
  log = logging.getLogger("ph.agent_loop")
@@ -149,7 +147,6 @@ class ReactLoopAgent:
149
147
  self.max_parallel_tool_calls = max_parallel_tool_calls
150
148
  self._phase = _Phase(turn=_last_turn_of(session))
151
149
  self._request_header_logged = False
152
- self._context_snapshot: str | None = None
153
150
  self._idle = anyio.Event()
154
151
  self._idle.set()
155
152
  self.inbox = Inbox(
@@ -276,11 +273,21 @@ class ReactLoopAgent:
276
273
 
277
274
  async def _pre_step(self, target: InboxTarget, turn: int, step: int) -> _PreparedStep:
278
275
  self._throw_if_canceled()
279
- claimed = self.inbox.claim(target, turn)
276
+ # **Assembled before the batch is claimed** (C4). `claim` is durable —
277
+ # it appends `agent/inbox/spliced`, which is what takes the messages out
278
+ # of the inbox for good — and `assemble` is an await that a person's
279
+ # interrupt can land in. Claimed first, a cancel there consumed the
280
+ # prompt and ran nothing: the typed line was gone from the inbox and
281
+ # never reached a model call. Nothing here needs the batch, so the
282
+ # ordering costs nothing and the claim now happens on the far side of
283
+ # the last cancel check before the step is proposed.
280
284
  assembly = await self.ctx.require(SYSTEM_PROMPT).assemble(self.ctx, agent=self)
281
285
  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)
286
+ claimed = self.inbox.claim(target, turn)
287
+ # Only when the conversation the model is shown lacks it (C12) — see
288
+ # `context_message`.
289
+ context = context_message(assembly, self.session.derive_messages())
290
+ messages = (*claimed, context) if context is not None else tuple(claimed)
284
291
 
285
292
  async def inner(request: PreStepRequest) -> PreStepDecision:
286
293
  return PreStepDecision(kind="enter", messages=request.messages)
@@ -295,34 +302,22 @@ class ReactLoopAgent:
295
302
  return _PreparedStep(kind="reject")
296
303
  return _PreparedStep(kind="enter", messages=decision.messages, assembly=assembly)
297
304
 
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
305
  async def _turn(self) -> bool:
320
306
  phase = self._phase
321
307
  self._throw_if_canceled()
322
308
  turn = phase.turn + 1
323
- self.session.append("turn/start", {"turn": turn})
309
+ _LOG.append(self.session, "turn/start", {"turn": turn})
324
310
  phase.turn = turn
325
311
  turn_ends: TurnEndReason | None = None
312
+ capped = False
313
+ """Whether any step in this turn stopped at `max_tokens`.
314
+
315
+ Kept beside `turn_ends` rather than inside it, because the two answer
316
+ different questions: what ends the turn, and what the person should be
317
+ told about it. Folding the second into the first made a `max-tokens` step
318
+ *end the turn* even where the loop had more to do — see where it is read
319
+ below.
320
+ """
326
321
  target: InboxTarget = "next-turn"
327
322
  try:
328
323
  while True:
@@ -341,21 +336,26 @@ class ReactLoopAgent:
341
336
  turn_ends = TurnEndReason(kind="completed")
342
337
  return False
343
338
  self._throw_if_canceled()
344
- self.session.append("step/start", {"turn": turn, "step": step})
339
+ _LOG.append(self.session, "step/start", {"turn": turn, "step": step})
345
340
  phase.step = step
346
341
  try:
347
342
  for message in decision.messages:
348
- self.session.append(
349
- "user/message", message.to_wire(), SurfaceIntent("append")
343
+ _LOG.append(
344
+ self.session, "user/message", message.to_wire(), SurfaceIntent("append")
350
345
  )
351
346
  assert decision.assembly is not None
352
347
  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
348
+ # **The outcome is sticky; the stopping is not.** A step that
349
+ # hit `max_tokens` must still be reported as such at the end
350
+ # of the turn, however the turn actually finishes — but a
351
+ # `None` here means "tools ran, there is more to do", and
352
+ # holding the earlier reason for it ended the turn with a
353
+ # `tool/result` the model never saw. The two were one
354
+ # variable, so the sticky answer was also the deciding one.
355
+ capped = capped or (step_end is not None and step_end.kind == "max-tokens")
356
+ turn_ends = step_end
357
357
  finally:
358
- self.session.append("step/end", {"turn": turn, "step": step})
358
+ _LOG.append(self.session, "step/end", {"turn": turn, "step": step})
359
359
  self._throw_if_canceled()
360
360
  if turn_ends is not None and not self.inbox.next_step:
361
361
  await self.ctx.serial("agent/turn-stopping", self, turn)
@@ -383,9 +383,10 @@ class ReactLoopAgent:
383
383
  self._report(error)
384
384
  raise
385
385
  finally:
386
- self.session.append(
386
+ _LOG.append(
387
+ self.session,
387
388
  "turn/end",
388
- {"turn": turn, "reason": (turn_ends or TurnEndReason(kind="completed")).to_wire()},
389
+ {"turn": turn, "reason": _turn_reason(turn_ends, capped).to_wire()},
389
390
  )
390
391
  if not self.inbox.has_pending:
391
392
  return False
@@ -398,6 +399,17 @@ class ReactLoopAgent:
398
399
  self._throw_if_canceled()
399
400
  system = render_prompt(assembly)
400
401
 
402
+ # **Which attempt this is, because a retried step keeps its coordinates**
403
+ # (G12). That is deliberate — `llm/retry` is legible in the log because
404
+ # of it — but it leaves `(turn, step)` naming a *step* where a model
405
+ # *call* is what needs the name. Two attempts minted the same tool-call
406
+ # ids and `recorded_steps` had to infer the boundary between them.
407
+ #
408
+ # **Counted here because this is the only place every retry passes**,
409
+ # by the row that asked (G13): the attempt is the total, and a policy's
410
+ # budget is its own share or the total as it chooses.
411
+ retries: Counter[str] = Counter()
412
+ attempt = 0
401
413
  while True:
402
414
  request = await self._build_request(turn, step, assembly, system)
403
415
  assembler = BlockAssembler()
@@ -407,13 +419,28 @@ class ReactLoopAgent:
407
419
  self._throw_if_canceled()
408
420
  async for chunk in stream:
409
421
  self._throw_if_canceled()
422
+ # **Named before it is logged** (G11): a tool call the
423
+ # provider did not name gets its id here, so the raw record
424
+ # and the assembled message agree about it. This is the only
425
+ # consumer that pairs a call to a result, and the only one
426
+ # holding the coordinates the id is built from.
427
+ chunk = named_call(chunk, turn, step, attempt)
410
428
  # Raw chunks are logged before assembly, so the log carries
411
429
  # token-level replay fidelity even for a stream that later
412
430
  # fails.
413
431
  chunk_seqs.append(
414
- self.session.append(
432
+ _LOG.append(
433
+ self.session,
415
434
  "assistant/chunk",
416
- {"turn": turn, "step": step, "chunk": chunk.to_wire()},
435
+ {
436
+ "turn": turn,
437
+ "step": step,
438
+ # The call's own coordinate, so `recorded_steps`
439
+ # reads the boundary between two attempts rather
440
+ # than inferring it (G12).
441
+ "attempt": attempt,
442
+ "chunk": chunk.to_wire(),
443
+ },
417
444
  ).seq
418
445
  )
419
446
  assembler.push(chunk)
@@ -431,12 +458,31 @@ class ReactLoopAgent:
431
458
 
432
459
  finish = assembler.finish
433
460
  if finish.kind in ("error", "aborted"):
434
- action = await self._request_error(turn, step, request, finish)
461
+ action, by = await self._request_error(turn, step, retries, request, finish)
435
462
  if action is None or action.kind != "retry":
436
463
  failure = finish.failure or LlmFailure(
437
464
  message="model request failed", code="UNKNOWN"
438
465
  )
439
466
  raise LlmError(failure.message, failure.code, failure)
467
+ retries[by] += 1
468
+ attempt = retries.total()
469
+ # The loop's own record of the call it is about to make again,
470
+ # beside `step/start` for the first one (P1): the log's answer
471
+ # to "how many model calls", whichever row asked for the retry.
472
+ # `llm/retry` is `llm-retry`'s account of itself and a compaction
473
+ # retry writes none, so a ceiling counting those undercounts.
474
+ _LOG.append(
475
+ self.session,
476
+ "step/retry",
477
+ {"turn": turn, "step": step, "attempt": attempt, "by": by},
478
+ )
479
+ # **The wait is the loop's**, after the waterfall has settled: a
480
+ # row that slept inside it answered only after a later veto
481
+ # could no longer save the time, and a gate had to run *ahead*
482
+ # of it to refuse at all (P1 review).
483
+ if action.delay_ms > 0:
484
+ await anyio.sleep(action.delay_ms / 1000)
485
+ self._throw_if_canceled()
440
486
  continue
441
487
 
442
488
  blocks = assembler.blocks()
@@ -475,21 +521,38 @@ class ReactLoopAgent:
475
521
  return TurnEndReason(kind="completed") if outcome.concluded else None
476
522
 
477
523
  async def _request_error(
478
- self, turn: int, step: int, request: GenerateOptions, finish: FinishReason
479
- ) -> RequestErrorAction | None:
524
+ self,
525
+ turn: int,
526
+ step: int,
527
+ retries: Mapping[str, int],
528
+ request: GenerateOptions,
529
+ finish: FinishReason,
530
+ ) -> tuple[RequestErrorAction | None, str]:
531
+ """The chain's answer, and the row whose listener gave it.
532
+
533
+ Attributed by cordis rather than claimed on the action (G13): the retry
534
+ is counted under the plugin that granted it, which a row could otherwise
535
+ misname and which cordis already knows.
536
+ """
537
+
480
538
  async def inner(request_failure: RequestFailure) -> RequestErrorAction | None:
481
539
  return None
482
540
 
483
541
  failure = RequestFailure(
484
542
  agent=self,
543
+ session=self.session,
485
544
  turn=turn,
486
545
  step=step,
487
546
  provider=request.provider,
488
547
  failure=finish.failure or LlmFailure(message="model request failed", code="UNKNOWN"),
548
+ # A copy: the payload is a snapshot, and the loop goes on counting.
549
+ retries_by=dict(retries),
550
+ )
551
+ action, by = await self.ctx.waterfall_attributed(
552
+ "agent/request-error", failure, inner=inner
489
553
  )
490
- action = await self.ctx.waterfall("agent/request-error", failure, inner=inner)
491
554
  self._throw_if_canceled()
492
- return settled_or_none("agent/request-error", action, RequestErrorAction)
555
+ return settled_or_none("agent/request-error", action, RequestErrorAction), by
493
556
 
494
557
  def _append_assistant_message(
495
558
  self,
@@ -510,7 +573,9 @@ class ReactLoopAgent:
510
573
  data["usage"] = usage.to_wire()
511
574
  if interrupted:
512
575
  data["interrupted"] = True
513
- self.session.append("assistant/message", data, SurfaceIntent("append", tuple(chunk_seqs)))
576
+ _LOG.append(
577
+ self.session, "assistant/message", data, SurfaceIntent("append", tuple(chunk_seqs))
578
+ )
514
579
 
515
580
  async def _build_request(
516
581
  self, turn: int, step: int, assembly: PromptAssembly, system: str
@@ -531,7 +596,9 @@ class ReactLoopAgent:
531
596
  async def inner(proposal: RequestProposal) -> LlmCallConfig:
532
597
  return proposal.config
533
598
 
534
- proposal = RequestProposal(agent=self, turn=turn, step=step, config=seed)
599
+ proposal = RequestProposal(
600
+ agent=self, session=self.session, turn=turn, step=step, config=seed
601
+ )
535
602
  answered = await self.ctx.waterfall("agent/request", proposal, inner=inner)
536
603
  self._throw_if_canceled()
537
604
  proposed = settled("agent/request", answered, LlmCallConfig)
@@ -546,20 +613,21 @@ class ReactLoopAgent:
546
613
  )
547
614
  baseline = session.request_header()
548
615
  if not self._request_header_logged:
549
- session.append(
616
+ _LOG.append(
617
+ session,
550
618
  "request/header",
551
619
  {"header": header.to_wire(), "reason": "initial" if baseline is None else "resume"},
552
620
  )
553
621
  self._request_header_logged = True
554
622
  elif baseline is None or not header_equals(baseline, header):
555
- session.append("request/header", {"header": header.to_wire(), "reason": "change"})
623
+ _LOG.append(session, "request/header", {"header": header.to_wire(), "reason": "change"})
556
624
 
557
625
  resolved = self.ctx.require(LLM).resolve_model(proposed.provider, proposed.model)
558
626
  request_context = RequestContext(
559
627
  provider=proposed.provider, model=proposed.model, context_window=resolved.context_window
560
628
  )
561
629
  if session.request_context() != request_context:
562
- session.append("request/context", request_context.to_wire())
630
+ _LOG.append(session, "request/context", request_context.to_wire())
563
631
  self._throw_if_canceled()
564
632
 
565
633
  return GenerateOptions(
@@ -609,6 +677,45 @@ def _error_chain(error: BaseException) -> str:
609
677
  return ": ".join(parts)
610
678
 
611
679
 
680
+ def _turn_reason(ends: TurnEndReason | None, capped: bool) -> TurnEndReason:
681
+ """What `turn/end` records, given how the loop left and what it passed through.
682
+
683
+ **A cap is not erased by finishing.** A step that hit `max_tokens` cut the
684
+ model off, and the turn may then have carried on — a steer, a tool call,
685
+ another step — and ended tidily. Reporting that as `completed` tells the
686
+ person their answer is whole when part of it was dropped, so `max-tokens`
687
+ outranks the two endings that mean "nothing went wrong".
688
+
689
+ It does not outrank the three that say more: `aborted`, `blocked` and
690
+ `error` each name something the reader has to act on, and a cap somewhere in
691
+ the turn is the smaller fact beside them.
692
+
693
+ **`None` means an exception left the body, so it is `aborted`** (C9). Every
694
+ normal exit from `_turn` assigns `ends` first — both `break`s are guarded by
695
+ `turn_ends is not None` and both `return` paths set it — so a `None` arriving
696
+ here cannot be a turn that finished. It used to read as `completed`, and each
697
+ `except` branch in `_turn` existed to write over that default before the
698
+ `finally` could believe it.
699
+
700
+ Deriving it here instead of catching it there is what makes the coverage
701
+ total. A branch for `anyio.get_cancelled_exc_class()` still misses the
702
+ `BaseExceptionGroup` an anyio task group raises around one, and
703
+ `KeyboardInterrupt`, and `SystemExit` — each of which would have recorded a
704
+ turn that finished nothing as *completed*, so the resume path saw no open
705
+ turn to repair and a reader was told a story the run did not have.
706
+
707
+ `kind="user"` is the honest cause for that case: something outside the turn
708
+ decided, and the loop cannot see what. The harness's own cancellations arrive
709
+ as `AgentCanceled`/`Canceled`, are caught in `_turn`, and carry their real
710
+ cause.
711
+ """
712
+ if ends is None:
713
+ return TurnEndReason(kind="aborted", reason=AgentCancelCause(kind="user"))
714
+ if ends.kind == "completed":
715
+ return TurnEndReason(kind="max-tokens" if capped else "completed")
716
+ return ends
717
+
718
+
612
719
  def _as_failure(error: Exception) -> LlmFailure:
613
720
  """A raised error as a turn's `reason.error`, keeping its code if it has one.
614
721