samagotchi 0.154.1 → 0.156.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -6,955 +6,766 @@ and the attached TUI, a `--non-interactive` one-shot) runs turns the same way wi
6
6
  they render.
7
7
 
8
8
  ```
9
- Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoop#complete (chat)
10
- → per iteration: one generation, then ToolResponse.run_batch over the calls
11
- → ToolRunner#run per call → Guardrails::Gate → KernelLoop#dispatch_tool_call
9
+ Engine#run_turn → backend.complete(..., context:) → KernelLoop#run (native) or ChatLoop#complete (chat)
10
+ → per iteration: one generation, then ToolResponse.run_batch(context:) over the calls
11
+ → ToolRunner#run(call, context:) per call → Guardrails::Gate#evaluate(call, context:)
12
+ → KernelLoop#dispatch_tool_call(call, context) → the handler's ToolContext(kernel, context)
12
13
  → repeat until the model answers without tool calls, or the iteration limit
13
14
  ```
14
15
 
16
+ `context` is the turn's `TurnContext` (`lib/samagotchi/turn_context.rb`, see "Turn and TurnContext"): every step
17
+ of the tool path takes it as an argument; none of them reads the turn from the kernel, the wiring or the Engine's
18
+ ivars.
19
+
15
20
  ## Quick reference: the turn lifecycle
16
21
 
22
+ The skeleton is `Engine#run_turn` in `engine.rb`; read it for the order. Each step and what it owns:
23
+
17
24
  ```
18
- Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:, max_tool_output_chars:,
19
- pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:, input_waiting:)
20
- │
21
- ├── begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id:, input_waiting:)
22
- │ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
23
- │ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
24
- │ # @session = session, session.status = RUNNING, used memories absorbed,
25
- │ # TurnState#begin!(controller:, sink:, input_waiting:) (a new CancellationController when none
26
- │ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait,
27
- │ # delegate_result's wait and execute's early hand-over poll),
28
- │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
29
- │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
30
- │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
31
- │ # wall (MessageTime.now)}
32
- │
33
- ├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
34
- │
35
- ├── prepare_turn(turn, images)
36
- │ # Emits :turn_started {session_id, prompt, turn_id, at (the turn's wall time, Turn#wall: read once,
37
- │ # the user message's and the reminders' stamp too), continue: true (only on a continue),
38
- │ # resumed: true (only on a step-limit Continue: resumed: given),
39
- │ # images (only when there are any), cut_after (the model's cut wait, s)}
40
- │ # start_toolkit_suggestion: ToolkitSuggest's ask on its own thread from a copied snapshot (audit and on
41
- │ # mode: one eligibility; SideJudgments' :toolkit kind: one in flight, warn-not-raise). Audit never blocks the turn or changes the session; on mode
42
- │ # returns a ToolkitSuggest::Pending the turn joins below. See configuration.md (Toolkits) and
43
- │ # guardrails.md (System 1).
44
- │ # refresh_profile! (may ask the server's /props once)
45
- │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name, loads_tools … are set in
46
- │ # generate, after the hooks, from the final model),
47
- │ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
48
- │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
49
- │ # then (on mode) settle_toolkit_suggestion: waits for the ask up to System 1's timeout from its
50
- │ # start, or a Stop (which wins over an answer already in, and cuts the ask's request through
51
- │ # Pending#cancel_controller; a Stop after a timeout doesn't), and on a candidate picked ≥ threshold
52
- │ # ToolkitBinding#suggest sets the session's toolkit, origin suggested, saved, with the notice;
53
- │ # then start_context_judge (owner turns only: ContextCandidatesLog#look, and unless
54
- │ # context.candidates.mode is off, ContextJudge's asks on SideJudgments' :context kind, each answer to
55
- │ # ContextCandidateChoice#after_judged (on mode: attach to the session's folder, or a card held while a
56
- │ # turn runs and shown from run_turn's ensure, show_context_cards_after_turn); on a shared
57
- │ # local System 1 host they wait for the turn's end: started before warm_up_next_turn, whose warm-up
58
- │ # waits for them, or from run_turn's ensure; docs/context.md, The judge);
59
- │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, ToolIndex#refresh! for find_tools'
60
- │ # chi line, a prompt rebuild when it changed; a warn notice per plugin tool that arrived mid-session
61
- │ # with nothing to reach it, Registry#stranded_names). A Stop during the probes or the waits returns false
62
- │ # (the ask's line says canceled; an ensure marks one the turn never settled failed).
63
- │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
64
- │ # (a frozen copy of the history, the prompt, and resumed)
65
- │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
66
- │ # + the plugins' staged tool loads (ctx.load_tools: a kind "tool_change" system entry per bundle,
67
- │ # only when the turn's host takes tool changes; #append_tool_loads)
68
- │ # Returns false when a Stop came during the probes, the init wait or the toolkit ask, else true
69
- │
70
- ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
71
- │ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
72
- │ # @kernel.turn_settings = turn.settings with model_name, window_setting, llm_context (the
73
- │ # session's, the model's/host's, else llm_context.strategy) and price
74
- │ # (hosts.<name>.models.<id>.price), all read per turn
75
- │ # turn.limit = max_iterations or IterationLimit.for
76
- │ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
77
- │ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
78
- │ # context_status, empty_steps, empty_retries)
79
- │
80
- ├── publish_used_memories(session, on_event) # :used_memories_updated when there are any
81
- │
82
- ├── complete_turn(turn, result)
83
- │ # end_turn(turn, "completed" | "canceled"): under the event lock the session gets the kept
84
- │ # messages, goes IDLE, records last_turn (enqueued_id: the turn's input, from its origin;
85
- │ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
86
- │ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
87
- │ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
88
- │ # stamp_opening puts a Verify turn's `verify` and a completed Revise turn's `revises`
89
- │ # (re-targeted to its stack's top) on the opening message, metadata never sent, and
90
- │ # every end event carries `turn_id`, :turn_completed `verify` / `verdict` / `revises` when there are
91
- │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
92
- │ # refs: the AnswerRefs adder)
93
- │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
94
- │ # :answer_display {display, refs: N} to the observers when a hook changed the answer or
95
- │ # attached refs, or display: nil when the web was told to wait for it)
96
- │ # then the :session_end hook
97
- │
98
- ├── warm_up_next_turn(turn, result)
99
- │ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
100
- │ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
101
- │
102
- └── ensure: release_turn(probe_cancel_before)
103
- # TurnState#finish! (turn flag off, controller, sink and steers cleared)
104
- # SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
105
- # probe cancel swapped back
106
- # the context window cache dropped, so the next turn asks the server again
107
- # steers left logged as dropped, activity recorded, turn-scoped hooks cleared
25
+ Engine#run_turn(session, prompt, on_event:, …, origin:, continue:, resumed:, resume_from:, verify:, revises:, …)
26
+ ├── begin_turn → Turn (the TurnContext published with the running flag)
27
+ ├── Client.swap_probe_cancel (a Stop cuts this turn's /props probes)
28
+ ├── prepare_turn → false at a STOP CHECK (stopped_before_model), else true
29
+ ├── generate → LLM::ModelResult
30
+ ├── publish_used_memories
31
+ ├── complete_turn
32
+ ├── @turn_warmup.after
33
+ └── ensure: release_turn, @context_judge.show_cards_after_turn, show_objective_proposals
108
34
  ```
109
35
 
36
+ | Step | Where | What it owns |
37
+ |---|---|---|
38
+ | `begin_turn` | `engine.rb` | A Verify turn's claim checked (`@verify_anchors.verified`, the sessions dir given); `session.status = RUNNING`; used memories absorbed; the `TurnContext` built **whole** (`new_turn_context`: `GuardrailWiring#begin_turn` gives git, participant and mode; `id:` the caller's or a new uuid; `settings` nil until `generate`), then `TurnState#begin!` publishes it with the controller (a new `CancellationController` when none is given), sink and `input_waiting:` peek in one step; a recap in flight invalidated. `run_turn` sets `turn.resumed` / `turn.resume_from` only on a continue. |
39
+ | `prepare_turn` | `engine.rb` | `:turn_started` (an image error raised after it); the toolkit suggestion's `start`; `refresh_profile!`; `turn.prepared_settings = turn_settings(session)` and `refuse_images!`; **STOP CHECK A** (abandons the suggestion `:canceled`); `announce_guardrail_failures`, `start_init_tasks!` / `await_init_tasks`, `apply_staged_tools!`; the suggestion's `settle`, the judge's `start`, the objectives' `incoming`; `apply_toolkit!` (`Registry#toolkit`, `ToolIndex#refresh!`, a prompt rebuild when it changed, a warn per stranded plugin tool); `refresh_participants!`; **STOP CHECK B** (before the hooks run or a reminder is used up); `:session_start` (first turn) and `:before_turn` hooks; `turn_messages`. Its ensure abandons the suggestion `:failed` (nothing once settled) and calls `finish`. |
40
+ | `turn_messages` | `engine.rb` | System head + history + due reminders (`:reminder_injected`; none in a participant's turn) + the user message (not on a continue) + the plugins' staged tool loads (`append_tool_loads`, `engine/toolkit.rb`). `turn.messages` is set first and grown in place, so a failure meanwhile keeps what is there. |
41
+ | `generate` | `engine.rb` | One `EffectiveModel` **after the hooks** (which may switch the model): `sync_kernel_client!`, the settings completed (`model_name`, `window_setting`, `llm_context`, `price`, `loads_tools`), the turn's `ToolSurface` built, `turn.context.with(…)` published by `TurnState#replace_context`; `turn.limit` (`IterationLimit.for`); `backend(model).complete(…, pending_input: turn_drain(pending_input), reasoning_since:, context:)`. |
42
+ | `complete_turn` / `end_turn` | `engine/turn_end.rb`, `engine.rb` | See "Turn ends" below. |
43
+ | `@turn_warmup.after` | `TurnStages::Warmup` | Local llama.cpp, native loop only: prefills the next turn's prompt (prompt-caching.md, "The turn-end warm-up"). Never fails the turn. |
44
+ | `release_turn` | `engine/turn_end.rb` | `TurnState#finish!`, `SteerCut#finished!`, probe cancel swapped back, the context window cache dropped (the next turn asks the server again), steers left logged as dropped, activity recorded, turn-scoped hooks cleared. Then, after the flag is released, the judge's cards and `show_objective_proposals` (the objectives' `finally`: a completed turn's proposal cards shown, any other's dropped). |
45
+
46
+ `Engine::Turn` (in `engine.rb`) carries the lifecycle fields; `turn.id`, `session`, `origin` and `controller` read
47
+ its context.
48
+
49
+ **Turn ends.** `end_turn(turn, outcome)` does one step under the event lock: the session gets the kept messages,
50
+ goes IDLE, records `last_turn` (its `enqueued_id` from the origin; a continue turn's origin carries its offer
51
+ turn's, `ContinueOffer#run_continue_turn`), the working set and the objectives' view (`announce_objectives`) go out, and the end
52
+ event is emitted (`:turn_completed` / `:turn_canceled`, every one carrying `turn_id`). Before the replace,
53
+ `@verify_anchors.stamp_opening` puts `verify` / `revises` on the opening message (metadata, never sent) and
54
+ `record_verdict` adds a Verify turn's verdict. `complete_turn` then runs `@objectives_stage.outgoing` (not for a
55
+ canceled or exhausted turn), the `:after_turn` hook (with `present:` and `refs:`, see "Hooks"),
56
+ `store_answer_display` (one replace; `:answer_display` to the observers when a hook changed the answer or attached
57
+ refs) and the `:session_end` hook. `TurnEndings` (`turn_endings.rb`) holds the pure builders (`kept_messages`,
58
+ `failed_messages`, `ctrl_c_messages`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards to it.
59
+
60
+ ### Turn stages
61
+
62
+ A step that keeps state across the turn is a collaborator under `lib/samagotchi/turn_stages/`, built in
63
+ `Engine#initialize`, **never reading the Engine's ivars**, and called by name from the skeleton. **It takes values,
64
+ not callbacks**: what lives as long as the Engine goes into its constructor (the side asks, the host registry, the
65
+ toolkit binding, `TurnState`, the model settings, the reminder queue); what the Engine knows at the call (the
66
+ recorder, the sessions dir, the owner's messages, the running conversation, the host's probe) goes with each call,
67
+ read on the caller's thread and held only for that call. At turn time the sessions dir is the turn's
68
+ (`TurnContext#state_dir`, set from `session_state_dir` by `new_turn_context`); an off-turn call gets `state_dir:`.
69
+ `Objectives` and `VerifyAnchors` are built early; `ToolkitSuggestion` and `ContextJudge` in `build_tools_and_plugins`,
70
+ before the plugins load (a plugin may reach them as it loads); `Warmup` after `wire_kernel`. Their state is their own.
71
+ The Engine's public methods forward to them (`warm_toolkit_suggestion!`, `announce_objectives`, `propose_objective`,
72
+ the private `judge_late_candidates`, `show_objective_proposals`); each stage's builder is in its `engine/*.rb` part.
73
+
74
+ **The recorder.** A stage saves its System 1 asks as runs through a `SystemOne::Recorder` (`system_one/recorder.rb`,
75
+ `Data.define(:runs, :session_model)`; `#wrap(client, judgment, session, mode:)`, `#saving?`), which
76
+ `Engine#system_one_recorder` builds **fresh at each stage call**, never kept: `runs` from `#system_one_runs` (Config's
77
+ `system_one.runs` read then), `session_model` from `effective_model_name` then, so `outgoing` after a before_turn
78
+ hook's model switch names the new model and `incoming` the old one. Building one never raises: on a failure
79
+ `Recorder::NONE` (no runs, every client as it was, no audit ask; `system_one_recording_failed`). Not the
80
+ `TurnContext`'s model fields: those are frozen at `generate` with the bare guardrail name.
81
+
82
+ | Stage | Built with | Called with | Called | State |
83
+ |---|---|---|---|---|
84
+ | `TurnStages::ToolkitSuggestion` (ToolkitSuggest's ask; configuration.md (Toolkits), guardrails.md (System 1)) | side judgments, host registry, toolkit binding, tools | `start(turn, recorder:)`; `settle`, `abandon(turn, why)`, `finish` (the turn); `warm!(session)` | `start` before `refresh_profile!`; `settle` after `apply_staged_tools!` (on mode: waits up to System 1's timeout or a Stop, which wins over an answer already in; a pick ≥ threshold → `ToolkitBinding#suggest`); `abandon` at STOP CHECK A and in the ensure, then `finish`; `warm!` / `stop_warmup` (Worker start, `shutdown`) | the turn's `ToolkitSuggest::Pending` (turn thread only; another turn's is never touched; dropped at `finish`), the warm-up thread |
85
+ | `TurnStages::ContextJudge` ([attached-context.md](attached-context.md), [context.md](../context.md) "The judge") | side judgments, host registry, `TurnState`, `show_card:`, `current_session:` | `start(turn, recorder:)`; `late_candidates(session, messages:, recorder:, state_dir:)`; `show_cards_after_turn` | `start` after `settle`, before `apply_toolkit!` (owner turns only); `late_candidates` from `judge_late_candidates` (which returns on no session or a review session first, before reading anything else); `show_cards_after_turn` after `release_turn` | the cards an answer brought while a turn ran, under their mutex |
86
+ | `TurnStages::Objectives` ([objectives.md](objectives.md)) | side judgments, host registry | `incoming(turn, recorder:)`, `outgoing(turn, result, recorder:)`, `note(session, note, recorder:, state_dir:)`, `announce(session, state_dir:, running:)` → the event or nil, `propose(proposal)`, `finally(turn)` → the cards | `incoming` after the judge's `start` (owner and human prompts only); `announce` from `announce_objectives` (inside `end_turn`'s step, and between turns from any thread); `outgoing` in `complete_turn` before `:after_turn`; `note` in `add_context_note`; `propose`; `finally` from `show_objective_proposals` in the ensure | the turn's proposals under their mutex (drained at `finally`), the last view announced (touched only under the event lock); the `ObjectivesAudit` is built per call |
87
+ | `TurnStages::Warmup` (PromptWarmup) | `PromptWarmup`, kernel, `TurnState`, model settings, reminder queue, `system_prompt` | `after(turn, result, next_turn_waiting:)` | after `complete_turn` (reads `turn.context.settings`) | none (PromptWarmup keeps its pin and thread) |
88
+ | `TurnStages::VerifyAnchors` | nothing | `verified(session, anchor, state_dir:)`; `stamp_opening`, `record_verdict` | `verified` in `begin_turn` (before any `TurnContext`); `stamp_opening`, `record_verdict` inside `end_turn`'s step | none |
89
+
90
+ **Objectives' announce and cards (the Engine does the effects).** `Engine#announce_objectives` takes the event lock,
91
+ reads the sessions dir and `live_conversation` in it, calls the stage's `announce` and announces the event it returns
92
+ in the same step, so the last view is guarded by the lock that orders the events; it logs a failure
93
+ (`objectives_announce_failed`), never raises. `show_objective_proposals` shows the cards `finally` returns and logs a
94
+ failure (`objectives_proposal_failed`).
95
+
96
+ **Why three callables stay** (`spec/turn_stages/constructor_contract_spec.rb` counts the Procs a built Engine's stages
97
+ hold, an audit's included: 3). ContextJudge's `show_card` and `current_session` are read on the asks thread when an
98
+ answer comes, after the call that started the asks returned: a value given at the call would be stale. Warmup's
99
+ `system_prompt` is built last, only once every check passed, for its side effects (the memory index metric, the
100
+ prompt notes). The warm-up's other late reads stay lazy too: the model after the canceled/resumable check, the
101
+ host's `next_turn_waiting` probe only after the target check. Warmup keeps the kernel (ToolHost's later).
102
+
103
+ On a shared local System 1 host the judge's asks wait for the turn's end: started before `TurnStages::Warmup#after`,
104
+ whose warm-up waits for them, or from `run_turn`'s ensure (docs/context.md, The judge).
105
+ `spec/turn_stages_wiring_spec.rb` pins the stages' order among the turn's steps, their threads, the saved runs'
106
+ fields and the late and error paths.
107
+
110
108
  ### Error paths
111
109
 
112
110
  ```
113
111
  StandardError in run_turn:
114
- → if turn.ended (it came from the after_turn/session_end hooks): logged as post_turn_error, re-raised
115
- → else: end_turn(turn, "failed", save: true) (a save that fails there is logged, turn_end_save_failed)
116
- # Both loops call LLM::FailedTurn.attach(e, conversation) before re-raising, so
117
- # failed_messages (TurnEndings) keeps the loop's partial conversation (tool iterations included),
118
- # else turn.messages, with TurnNote.failed(summary, continued: turn.continue) at the tail.
119
- # :turn_failed {error_class, message, duration_ms}; a provider error adds
120
- # error_kind, retryable, host and summary.
121
- → re-raised
122
-
123
- Interrupt (Ctrl-C; a one-shot only: a worker traps INT, see the signal row below):
124
- → if turn.ended: re-raised only
125
- → turn.controller.cancel!(:ctrl_c)
126
- → end_turn(turn, "canceled"): ctrl_c_messages keeps the loop's conversation, else turn.messages
127
- # (it came before the loop), with TurnNote.cancelled(:ctrl_c) at the tail.
128
- # Both loops attach their conversation to the Interrupt as to a failure
129
- # (LLM::FailedTurn.attach), so the turn's finished iterations stay, as after a Stop,
130
- # and the cancel note names the tasks the turn started. One mid-tool is answered:
131
- # ToolResponse.run_batch gives every call left an interrupted result (the native
132
- # loop's batch entry joins them after the finished ones), so no call is kept without
133
- # its result. A half-streamed reply is not kept (a Stop keeps it, [interrupted]).
134
- → :turn_canceled {cancellation_reason: :ctrl_c, duration_ms}
135
- → re-raised (the caller decides whether to exit)
136
-
137
- A Stop (Engine#cancel_current_turn!, e.g. the Bridge's POST cancel) or a hook's stop_turn:
138
- → not an error: the loop returns LLM::ModelResult(canceled: true, conversation: ...)
139
- → complete_turn keeps the loop's conversation (tool iterations included) with a
140
- TurnNote.cancelled note; a text streamed before the cancel stays, marked [interrupted]
141
-
142
- A worker's TERM, HUP or INT (WorkerSignals, trapped by a spawned worker only):
143
- → a Stop with reason :signal (Worker#signalled, off the trap, retried until the loop leaves)
144
- → :turn_canceled {cancellation_reason: :signal}, saved by the Worker as after any turn;
145
- Worker#run returns :signal (:stopped with the stop marker), takes no new work
146
- → not ended within Worker::SIGNAL_GRACE (a turn that ended and runs its after_turn hooks gets
147
- SIGNAL_HARD_EXIT more, uncanceled): the session saved as it is, SignalException raised in the
148
- loop's thread, only while the loop runs (never into start, serve or the teardown, which runs whole)
149
- → the process ends SIGNAL_HARD_EXIT (10 s) after that at the latest (exit!); a signal after the
150
- loop left brings only that hard exit, and run_session_loop restores the default handlers
151
- → run_session_loop: exit 0, no discard; input queued behind the stopped turn wakes a new worker
152
-
153
- The effective model doesn't resolve (a broken host config, HostRegistry#resolve raising):
112
+ → turn.ended (raised by the after_turn/session_end hooks): logged post_turn_error, re-raised, no second ending
113
+ → else end_turn(turn, "failed", save: true) (a failing save logged, turn_end_save_failed): failed_messages keeps
114
+ the loop's partial conversation (both loops LLM::FailedTurn.attach it), else turn.messages, with
115
+ TurnNote.failed at the tail; :turn_failed {error_class, message, duration_ms, + error_kind, retryable, host,
116
+ summary for a provider error}; re-raised
117
+
118
+ Interrupt (Ctrl-C; a one-shot only, a worker traps INT):
119
+ → turn.ended: re-raised only
120
+ → controller.cancel!(:ctrl_c); end_turn(turn, "canceled"): ctrl_c_messages keeps the attached conversation (the
121
+ finished iterations stay, as after a Stop) with TurnNote.cancelled(:ctrl_c); a call mid-batch gets an
122
+ interrupted result (ToolResponse.run_batch), so no call is kept without its result; a half-streamed reply is
123
+ not kept; :turn_canceled {cancellation_reason: :ctrl_c}; re-raised (the caller decides whether to exit)
124
+
125
+ A Stop (Engine#cancel_current_turn!, the Bridge's POST cancel) or a hook's stop_turn:
126
+ → not an error: LLM::ModelResult(canceled: true, conversation:); complete_turn keeps the conversation with a
127
+ TurnNote.cancelled note; text streamed before the cancel stays, marked [interrupted]
128
+
129
+ A worker's TERM, HUP or INT (WorkerSignals, a spawned worker only):
130
+ → a Stop with reason :signal (Worker#signalled, off the trap, retried until the loop leaves); saved as after any
131
+ turn; Worker#run returns :signal (:stopped with the stop marker) and takes no new work
132
+ → not ended within Worker::SIGNAL_GRACE (a turn already in its after_turn hooks gets SIGNAL_HARD_EXIT more,
133
+ uncanceled): the session saved as it is, SignalException raised in the loop's thread, only while the loop runs
134
+ (never into start, serve or the teardown, which runs whole)
135
+ → exit! Worker::SIGNAL_HARD_EXIT (10 s) after that at the latest; a signal after the loop left brings only that
136
+ hard exit
137
+ → WorkerBoot.run_session_loop restores the default handlers; exit 0, no discard; input queued behind the stopped turn
138
+ wakes a new worker
139
+
140
+ The effective model doesn't resolve (HostRegistry#resolve raising):
154
141
  → the rescued readers give their fallback (SessionModelSettings#model_setting) and log effective_model_unresolved;
155
- a setting that fails on a resolved model gives its fallback and logs <setting>_unresolved
156
- (sampling, thinking, reasoning_history, steer_cut_after, llm_context, window, price, served,
157
- vision_adapter); served_probe_failed when asking the server for its served model fails;
158
- alias_note_failed, thinking_notice_failed likewise
159
- → WARN once per process for an (event, model, error class), DEBUG after (EffectiveModel::Unresolved)
160
- → session_state_snapshot never raises (its model fields are nil); a turn, stats_snapshot,
161
- chat_model? and system_prompt raise, as before
162
-
163
- A session's toolkit doesn't resolve (an unknown kit name: a typo, a bundle's kit that isn't installed; a base that
164
- doesn't resolve, a cycle), at a turn's start (ToolkitBinding#apply!):
165
- → that turn declares the full kit with the session's own added and removed tools on top (Toolkit#resolve's
166
- fallback); the saved toolkit stays as it is, so a reinstalled bundle gets it back
167
- → each warning (that fallback, a config.yml kit with a problem: Toolkit.kits) logged once per process as
168
- toolkit_unresolved (WARN), with a :guardrail_warning (label "toolkit") to the turn and the observers
142
+ a setting failing on a resolved model logs <setting>_unresolved (also served_probe_failed,
143
+ alias_note_failed, thinking_notice_failed); WARN once per (event, model, error class),
144
+ DEBUG after (EffectiveModel::Unresolved)
145
+ → session_state_snapshot never raises (its model fields nil); a turn, stats_snapshot, chat_model? and
146
+ system_prompt raise
147
+
148
+ A session's toolkit doesn't resolve (an unknown kit, a missing bundle's kit, a bad base, a cycle; ToolkitBinding#apply!):
149
+ → that turn declares the full kit plus the session's own adds and removes (Toolkit#resolve's fallback); the saved
150
+ toolkit stays, so a reinstalled bundle gets it back; toolkit_unresolved logged once per process, with a
151
+ :guardrail_warning (label "toolkit")
169
152
 
170
153
  Stopped before the model was asked (prepare_turn returned false):
171
- → stopped_before_model(turn) → end_turn(turn, "canceled")
172
- → nothing kept, :turn_canceled {cancellation_reason, cancelled_by, duration_ms}
173
- → LLM::ModelResult(text: "", canceled: true, cancellation_reason: reason)
154
+ → stopped_before_model → end_turn(turn, "canceled"): nothing kept; :turn_canceled; ModelResult(canceled: true)
174
155
  ```
175
156
 
176
- **Degrading.** Work that has a fallback when it fails (a prompt section left out, an empty list from the Bridge) runs
177
- in `Degrade.call(area, event, fallback:, rescue:) { … }` (`degrade.rb`): same rescue class and fallback as a bare rescue,
178
- plus one WARN in the debug log per process for each `[area, event]` (DEBUG after; nothing on stderr). A bare `rescue`
179
- whose body is only a literal (`nil`, `false`, `[]`, `{}`, `""`, `0`, or an array of only those: `[nil, nil]`) is flagged in lib by the `Samagotchi/SilentRescue`
180
- cop; a silence that is right (a client gone, a routine probe, Log's own internals) carries `# degrade-ok: why`.
157
+ ### Degrading
158
+
159
+ Work that has a fallback when it fails (a prompt section left out, an empty list from the Bridge) runs in
160
+ `Degrade.call(area, event, fallback:, rescue:) { … }` (`degrade.rb`): the same rescue and fallback as a bare
161
+ rescue, plus one WARN in the debug log per process for each `[area, event]` (DEBUG after; nothing on stderr). A bare
162
+ `rescue` whose body is only a literal (`nil`, `false`, `[]`, `{}`, `""`, `0`, or an array of only those) is flagged
163
+ in lib by the `Samagotchi/SilentRescue` cop; a silence that is right (a client gone, a routine probe, Log's own
164
+ internals) carries `# degrade-ok: why`.
181
165
 
182
166
  ## The model↔tool loop
183
167
 
184
- The Engine does not talk to models directly. `Engine#backend` picks the loop the effective model's host speaks:
168
+ The Engine does not talk to models directly. `Engine#backend` (`engine/models.rb`) picks the loop the effective
169
+ model's host speaks: an api in `LLM::Adapters::BY_API` (openai, anthropic) gets `LLM::ChatLoop` (one per Engine,
170
+ built on first use), anything else `LLM::NativeBackend` wrapping the Engine's `KernelLoop`.
185
171
 
186
- ```
187
- Engine#backend → backend_for(effective_model) # EffectiveModel.of(@host_registry, @model_binding.choice)
188
- → host entry chat? (an api in LLM::Adapters::BY_API: openai, anthropic): LLM::ChatLoop (one per Engine, built on first use)
189
- → else: LLM::NativeBackend wrapping the Engine's KernelLoop
190
- ```
172
+ Both implement `LLM::ModelBackend#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
173
+ model_name:, max_tool_output_chars:, pending_input:, reasoning_since:, context:)`. `reasoning_since:` is where the
174
+ run a step-limit Continue resumes started (the Engine's `resume_from`, checked in `Engine#resumed_run_start`).
175
+ `NativeBackend#complete` only calls `KernelLoop#run`. `ChatLoop` runs its own loop but uses the KernelLoop's tools,
176
+ hooks, gate and dispatch.
191
177
 
192
- Both implement the `LLM::ModelBackend` contract (`#complete(messages:, max_iterations:, on_stream_event:,
193
- cancel_controller:, model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)`; `reasoning_since:` is
194
- where the run a step-limit Continue resumes started, the Engine's `resume_from`). `NativeBackend#complete` only calls
195
- `KernelLoop#run` with the same arguments. `ChatLoop` runs its own loop but is built with the same KernelLoop,
196
- whose tools, hooks, gate and dispatch it uses.
178
+ **Each loop works on its own copy of the conversation**, not the Engine's `turn.messages`. The Engine keeps
179
+ `result.conversation` after a normal end or a Stop, the copy attached with `LLM::FailedTurn.attach` after a failure
180
+ or a Ctrl-C, and `turn.messages` only when the turn ended before the loop.
197
181
 
198
182
  ### KernelLoop (the native, raw-prompt loop)
199
183
 
200
- Runs models whose server takes a raw prompt (llama.cpp `/completion`). chi formats the prompt itself, per model profile (Gemma 4, Qwen 3.6), and
201
- parses tool calls out of the generated text.
202
-
203
- ```
204
- KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
205
- model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
206
- │
207
- │ turn = start_turn(...) # turn.conversation = prepare_conversation(messages): a copy,
208
- │ # old model messages without their thought blocks, but a resumed
209
- │ # run's (a step-limit Continue's reasoning_since:, #thought_floor:
210
- │ # all of them, a steer merged mid-run included; only a user entry
211
- │ # added after the run's last reply starts them past itself)
212
- │ each iteration:
213
- │ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
214
- │ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
215
- │ guidance line goes on the tail when the window fills past a threshold
216
- │ 3. Fire :before_generation, then stream one generation (:generation_started, :generation_chunk, :generation_completed)
217
- │ 4. Parse tool calls (ToolCallParser for the profile):
218
- │ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
219
- │ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
220
- │ A Qwen block left open is asked to be finished (QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT = 2)
221
- │ 5. Calls: dispatch_calls → ToolResponse.run_batch; one tool_response entry for the batch
222
- │ 6. No calls: an answer, an empty answer to retry (EmptyAnswerRetry), or queued input to answer
223
- │
224
- └──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
225
- ```
184
+ Runs models whose server takes a raw prompt (llama.cpp `/completion`). chi formats the prompt itself per model
185
+ profile (Gemma 4, Qwen 3.6) and parses tool calls out of the generated text. `KernelLoop#run` → `start_turn`
186
+ (`prepare_conversation`: a copy, old model messages without their thought blocks, except a resumed run's,
187
+ `keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since)`), then per iteration (`#iterate`):
226
188
 
227
- `turn.conversation` is the loop's own copy, not the Engine's `turn.messages`. A failure attaches it to the error
228
- (`LLM::FailedTurn.attach`), so `Engine#failed_messages` can keep it; an Interrupt (Ctrl-C) too, for
229
- `Engine#ctrl_c_messages` (see Error paths). Both are `TurnEndings`', with the other pure builders of a turn's end
230
- (`kept_messages`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards to it and keeps `end_turn`,
231
- `complete_turn`, `store_answer_display` and `record_last_turn`.
189
+ 1. Queued input (user lines, plugin steers) goes in at the boundary (`Steer.inject!`).
190
+ 2. The conversation is formatted (`Prompt.format_with_images`); a context guidance line goes on the tail past a
191
+ fill threshold.
192
+ 3. `:before_generation`, then one streamed generation.
193
+ 4. Tool calls parsed (`ToolCallParser` for the profile):
194
+ - Gemma 4: `<|tool_call>call:NAME{params}<tool_call|>`
195
+ - Qwen 3.6: `<tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>`
232
196
 
233
- ### ChatLoop (the chat APIs)
197
+ A Qwen block left open is asked to be finished (`QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT` = 2).
198
+ 5. Calls: `dispatch_calls` → `ToolResponse.run_batch`, one `tool_response` entry for the batch.
199
+ 6. No calls: an answer, an empty answer to retry (`EmptyAnswerRetry`), or queued input to answer.
234
200
 
235
- Runs hosts whose api has a chat adapter (`LLM::Adapters::BY_API`): `api: openai` on `LLM::OpenAIChat` (remote
236
- providers such as OpenRouter, Claude among others, and any OpenAI-compatible chat server) and `api: anthropic` on
237
- `LLM::AnthropicMessages` (Anthropic's Messages API; [anthropic.md](anthropic.md)). The Engine sets the adapter per
238
- turn for the effective model's host (`HostRegistry#adapter_for`). The model gets the tools as JSON schemas and returns
239
- structured tool calls.
201
+ Gemma 4's native contract (and thought context) is [gemma4-contract.md](gemma4-contract.md).
240
202
 
241
- ```
242
- ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controller:,
243
- model_name:, max_tool_output_chars:, pending_input:, reasoning_since:)
244
- │
245
- │ conversation = Array(messages).map(&:dup) # a shallow copy of each message
246
- │ Run.new(self, conversation, on_stream_event, cancel_controller, model_name, pending_input,
247
- │ start: reasoning_since)
248
- │ .call(max_iterations: max_iterations || 1, cap: KernelLoop.resolve_output_char_cap(max_tool_output_chars))
249
- │
250
- └──→ LLM::ModelResult(text, conversation, exhausted, canceled, tool_activity, ...)
251
- ```
252
-
253
- The differences from the native loop:
203
+ ### ChatLoop (the chat APIs)
254
204
 
255
- - The tools go in each request's `tools` field (`tool_definitions(conversation, model:)`), not in the system prompt.
205
+ Runs hosts whose api has a chat adapter: `api: openai` on `LLM::OpenAIChat` (OpenRouter, any OpenAI-compatible
206
+ server) and `api: anthropic` on `LLM::AnthropicMessages` ([anthropic.md](anthropic.md)). The Engine sets the
207
+ adapter per turn (`HostRegistry#adapter_for`), with the session id and the loop's own client (`ChatLoop#client`, the
208
+ effective model's `EffectiveModel#client`: what `#context_window` probes on a local host, and the host a request's
209
+ re-prefill is counted against on the Engine's `ReprefillMeter`, one for both loops: prompt-caching.md, "Reading
210
+ the numbers"). `Engine#backend_for` writes all three under `@chat_backend_mutex`, from whichever
211
+ thread asks (the turn's, or a `/model` switch's through `sync_kernel_client!`); the turn reads them on its own
212
+ thread. The chat loop never reads the kernel's client. `ChatLoop#complete` copies the messages (each `dup`ed) and runs a
213
+ `ChatLoop::Run` (`start: reasoning_since`, cap `ToolOutputCut.max_chars`). Differences from the native
214
+ loop:
215
+
216
+ - The tools go in each request's `tools` field (`ChatLoop#tool_definitions`, the turn's `ToolSurface#schemas`).
256
217
  On an adapter that loads tools (`#loads_tools?`: `AnthropicMessages`), the tools the session reaches but doesn't
257
- declare follow, `defer_loading: true` (`Tools::DeferredTools`; anthropic.md, "Deferred tools"); the Run keeps the
258
- last request's deferred names and logs a call of one (`tool_direct_deferred`).
259
- - The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
260
- `AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
261
- - The first system message carries `cache_split:` (from `SystemPrompt#stable_length`, which the Engine passes in
262
- as `stable_length:`), where the prompt's stable part ends. The adapter puts the first cache breakpoint there:
263
- `OpenAIChat` for a Claude model on a remote host (`LLM::PromptCache.mark`), `AnthropicMessages` on every chat
264
- request unless its dialect has no breakpoints or the host refused `cache_control` (`AnthropicWire.translate`,
265
- `Dialect#cache_breakpoints`); see prompt-caching.md and anthropic.md.
266
- - Each call's result is its own tool message, paired by `tool_call_id`.
267
- - A model turn's saved reasoning (`thinking:`) goes back as `reasoning_content` per the turn's
268
- `TurnSettings#reasoning_history` (`ReasoningHistory.resolve`, the session's own level first:
269
- `Session#reasoning_history`, `/reasoning-history`): `all` on every model message, `turn` past
270
- `ChatLoop#reasoning_boundary`: the Run's `@reasoning_start`, fixed once per Run by `ReasoningHistory.run_start`
271
- (the rule `KernelLoop#start_turn` uses too) from the Run's start (the conversation length at `#complete`), so a
272
- continue turn (reminder, context wake, delegate report wake) replays nothing of the run before. Entries the Run
273
- merges (a person's line, a plugin's steer, a Continue's text) don't move it, as natively (nothing re-sanitizes
274
- within a run): the request after a steer extends the one before. A step-limit Continue's Run starts where the run
275
- it resumes did: `#complete`'s `reasoning_since:` (the Engine's `resume_from`, checked against the messages' length
276
- in `Engine#resumed_run_start`), past a user entry added after that run's last reply (a `!cmd`'s output). Without
277
- `since:` (`#reasoning_chars` from outside a Run) the boundary is the last query (a `role: "user"` or `turn_start`
278
- entry). `Run#prompt_chars` counts it (`#reasoning_chars`).
218
+ declare follow with `defer_loading: true` (`ToolSurface#deferred_for`; anthropic.md, "Deferred tools"); a call of
219
+ one is logged `tool_direct_deferred`.
220
+ - The conversation goes out in the OpenAI wire format (`wire_messages`); `AnthropicMessages` translates it again
221
+ (`LLM::AnthropicWire`). Each call's result is its own tool message, paired by `tool_call_id`.
222
+ - The first system message carries `cache_split:` (`SystemPrompt#stable_length`); where the adapters put cache
223
+ breakpoints is prompt-caching.md's and anthropic.md's.
224
+ - **Reasoning sent back** (`reasoning_content`) follows `TurnSettings#reasoning_history` (`ReasoningHistory.resolve`,
225
+ the session's own level first): `all` on every model message, `turn` past `ChatLoop#reasoning_boundary`, the Run's
226
+ `@reasoning_start`, fixed **once per Run** by `ReasoningHistory.run_start` (the rule the native loop uses too).
227
+ So a continue turn (reminder, context wake, report wake) replays nothing of the run before, and entries the Run
228
+ merges (a steer, a person's line) don't move it: the request after a steer extends the one before. A step-limit
229
+ Continue's Run starts where the run it resumes did (`reasoning_since:`). Outside a Run (`#reasoning_chars`) the
230
+ boundary is the last query.
279
231
  - On an adapter that sends its blocks back (`#replays_content?`: `AnthropicMessages`), a model turn's saved blocks
280
- (`anthropic_content`, the response's `provider_content`, saved by `Run#with_thinking`) go back verbatim, thinking
281
- blocks with their signatures (`#replay_blocks`): not for an interrupted entry or one with an unpaired call, and
282
- without the thinking blocks before the reasoning boundary (the last model entry with tool calls keeps them).
283
- `#reasoning_history` floors `drop` at `turn` there, since Messages needs the running run's thinking blocks.
232
+ (`anthropic_content`, saved by `Run#with_thinking`) go back verbatim with their signatures (`#replay_blocks`), not
233
+ for an interrupted entry or one with an unpaired call, and without thinking blocks before the reasoning boundary.
234
+ `#reasoning_history` floors `drop` at `turn` there.
284
235
  - A 400 about reasoning (`BadRequest#reasoning_refused?`) for a request that sent reasoning back asks the step again
285
- without it (not on a replaying adapter, where the body would be the same); when that goes through,
286
- `#reasoning_history_refused!` marks the model (worker lifetime; the loop's `LLM::RefusalMemo`, as for the thinking
287
- and sampling refusals) and
288
- `:reasoning_history_refused` goes out. When it is refused too, the thinking-fields fallback (`#thinking_refused!`,
289
- `:thinking_refused`) runs on it, still without reasoning. Up to three requests per step. A 400 about a thinking
290
- block's signature (`BadRequest#thinking_signature?`) or a refused binding control
291
- (`#thinking_binding_unsupported?`) is not one: `ProviderErrors` leaves `reasoning_refused?` false for them, so one
292
- bad block never turns reasoning off for the model.
236
+ without it (not on a replaying adapter); when that goes through, `#reasoning_history_refused!` marks the model
237
+ for the worker's life (`LLM::RefusalMemo`) and `:reasoning_history_refused` goes out; refused again, the
238
+ thinking-fields fallback (`#thinking_refused!`) runs. Up to three requests per step. A bad thinking signature
239
+ (`#thinking_signature?`) or a refused binding control (`#thinking_binding_unsupported?`) is **not** a reasoning
240
+ refusal (`ProviderErrors`), so one bad block never turns reasoning off for the model.
293
241
 
294
242
  ### Shared: ToolResponse (per batch) and ToolRunner (per call)
295
243
 
296
- Both loops run a batch of calls the same way:
297
-
298
244
  ```
299
- One iteration with tool calls:
300
- KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
301
- → ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
302
- # chat passes a block, called with each run as it finishes
303
- → :tool_dispatch_started {iteration, call_count}
304
- → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
305
- on_stream_event:, max_tool_output_chars:)
306
- → under TurnSettings#loads_tools, a direct call of a source tool's acts_as (mcp_fs_read) is wrapped
307
- into the call_tool call it stands for (Registry::Index#wrap, ToolIndex#wrap)
308
- → first, the call's entry unwraps it (Registry::Entry#unwrap: a call_tool of chi/<name>, a built-in or
309
- a plugin tool, becomes that tool's call with via: "call_tool", ToolIndex#unwrap); the rest sees the
310
- real call
311
- → a tool the session's toolkit leaves out (Registry#left_out; with via:, one Registry#indexed doesn't
312
- reach) skips the gate: no hooks, no card; KernelLoop#dispatch answers it (the call_tool route for a
313
- reachable tool, the not-loaded error for a removed one). Under TurnSettings#loads_tools a reachable
314
- built-in or plugin tool called by its own name is no such call: it runs through the gate, and KernelLoop#dispatch
315
- takes it (#loaded_entry)
316
- → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
317
- → :tool_call_started
318
- → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
319
- → KernelLoop#dispatch_tool_call, or the denial text
320
- → :tool_call_completed, then the :after_tool_call hook
321
- → returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
322
- shown_label, refs, diff and output_file; fields a call doesn't produce are nil
323
- → :tool_dispatch_completed {iteration, call_count}
324
- → native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
325
- chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
326
- capped output
327
- ids: ToolIds.next_ids, chi's own ids for the outputs ("t41"), saved on the entry as tool_ids
328
- (one per call of the batch for native, one for chat); LLM context edits name outputs by them
245
+ KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
246
+ → ToolResponse.run_batch(runner, calls, context:, iteration:, emit:, on_stream_event:, cap:)
247
+ → :tool_dispatch_started
248
+ → per call: ToolRunner#run(call, context:, …)
249
+ wrap (under loads_tools: a direct call of a source tool's acts_as → its call_tool call; Registry::Index#wrap)
250
+ unwrap (Registry::Entry#unwrap: call_tool of chi/<name> → that tool's call, via: "call_tool")
251
+ a tool the toolkit leaves out (ToolSurface#left_out) skips the gate: KernelLoop#dispatch answers it
252
+ Guardrails::Gate#evaluate → :tool_call_started → Gate#settle_ask (an ask) → KernelLoop#dispatch_tool_call
253
+ or the denial text → :tool_call_completed → :after_tool_call hook
254
+ → a ToolRun value (output, capped_output, truncated, activity, images, shown_params, shown_label, refs,
255
+ diff, output_file; nil when a call doesn't produce one)
256
+ → :tool_dispatch_completed
257
+ → native: ToolResponse.joined(runs, ids:), one entry; chat: ToolResponse.single(run, tool_call_id:, ids:) per call
258
+ ids: ToolIds.next_ids ("t41"), saved as tool_ids; LLM context edits name outputs by them
329
259
  ```
330
260
 
331
- **ToolRunner** (`ToolRunner#run`):
332
- - Unwraps a call_tool of a built-in first (`Registry::Entry#unwrap`), so `original`, the params and the label
333
- are the built-in's and no "ran as:" line follows.
334
- - Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
335
- replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
336
- - Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
337
- title, called_as, via, view, ref, refs}. The row fields (title, called_as, via, view, ref) come from `ToolRowFields.for`, which the
338
- reload (`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
339
- inside `activity` on the completed event; `EVENT_KEYS` (view, ref, refs) go top-level on both events.
340
- `Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
341
- `EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
342
- read/write/edit's file against the cwd, `{kind: "file", path, line?, end_line?}`); the web opens it in the editor
343
- that `Web::Editor` (web.editor) names, for a local viewer only (`App#viewer_capabilities`).
344
- `refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
345
- once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
346
- `Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
347
- (`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
348
- stored under the real path; at most `RowRefs::MAX_REFS` and `MAX_BYTES`). `ToolRowFields.for` can't build it (no plugins on a reload), so the run carries it
349
- as `refs:`, saved with the result as `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent), and
350
- `MessageParts#tool_part` reads it back through `RowRefs.for_display` (checked again). The page binds them to the
351
- row's title (`refs.js bindRowRefs`, from `turn_block.js` live and on a reload): the first ref on it, the rest in its
352
- menu.
353
- - Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
354
- - Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
355
- `KernelLoop#dispatch` ("Error: no such tool ...", never repeating the wrong name).
356
- - For edit and write: diffs the file before and after, and refreshes a memory's index line
357
- (`MemoryBundle::IndexSync`).
358
- - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
359
- - Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` (a `memory_read`'s is `memory.read_max_chars`, 0: none, with an overflow guard at half a known window) by the tool's `ToolOutputCut`
360
- policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
361
- kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
362
- and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
363
- (`ToolOutputFiles`, through `KernelLoop#session_dir`, which the Engine sets). See
364
- [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
365
- event and the after_tool_call hook get that text.
366
- - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
367
- activity, images, diff, waited_ms, view, ref, refs}, then fires `:after_tool_call` {iteration, tool, output,
368
- status, call, elapsed_ms, result}.
369
- `output_file` (the saved whole output) is also on the run hash; the session doesn't keep it (the cut text names it).
370
- - The activity's status (`ToolActivity#tool_activity_status`; a call the gate denied is `blocked`, set by
371
- `ToolRunner#denied`): `stopped` for a call the turn's Stop (or a signal to chi, or the session's stop) left
372
- unrun (`Tools::Execute::NOT_RUN_ON_STOP`, `NOT_RUN`) or an execute it killed (`STOPPED_BY_USER`, `STOPPED_BY`),
373
- and for a `task_wait` whose header (before `output_tail:`) says `wait_result: canceled` or `status: stopped`;
374
- `ok` for an execute handed over as a task (`#handed_over?`: its output tail may hold any `exit: N`); `error` for
375
- any other `Error:` result, for a `task_wait` whose header says `status: failed`, and for an execute that exited
376
- non-zero (a numberless `exit:` line, killed by a signal, counts), except a grep-family search that found nothing
377
- (`ToolActivity.no_match?`:
378
- exit 1, no stderr block, the last step is grep/egrep/fgrep/rg/ag/pgrep, no `&&` after a step that can fail
379
- silently or sends output to /dev/null, no background `&`, no earlier exit/return or `set -e`; when unsure,
380
- not a no-match). That one stays `ok` with `no_match: true` on the activity, which SessionMetrics keeps on the tool
381
- record and the Bridge's TurnAccumulator on the part, for the web row's "no match" word. The model's text is
382
- unchanged.
383
-
384
261
  A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
385
- `stop_turn`, the gate denies every remaining call of the batch.
262
+ `stop_turn`, the gate denies every remaining call of the batch. Under the surface's `loads_tools` a reachable
263
+ built-in or plugin tool called by its own name is not a left-out call: it runs through the gate
264
+ (`ToolSurface#entry_for`).
265
+
266
+ **ToolRunner** (`tool_runner.rb`) reads of the turn only the `context:` it is given (folders, the settings' vision,
267
+ LLM context layers and `loads_tools`, the participant's author and mode); the kernel gives it only what lives as
268
+ long as the Engine (tools, hooks, the gate). The rules an edit gets wrong:
269
+
270
+ - The gate runs **before** `:tool_call_started`, so the event shows the call that will run (a before_tool_call
271
+ hook may replace it; known-names corrects a misspelled name this way). An ask is settled **after** it, so a UI
272
+ shows the tool line, then the approval. An unwrapped call_tool shows the built-in's params and label.
273
+ - **Row fields.** `ToolRowFields.for` builds the row fields (title, called_as, via, view, ref) live and on a reload
274
+ (`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`). `ToolRowFields::ACTIVITY_KEYS` travel inside
275
+ `activity`; `EVENT_KEYS` go top-level on both events. `Bridge::TurnAccumulator` and the web (`activity.js`
276
+ `ACTIVITY_FIELDS` / `EVENT_FIELDS`) copy them by these lists, **so a new row field is added there too**. `ref` is
277
+ `FileRef.for` (opened in `Web::Editor`'s editor, for a local viewer only).
278
+ - **Row refs.** A plugin tool's `refs:` lambda (`ToolSpec#per_call`) runs once per call through `RowRefs.for`, which
279
+ never raises (`tool_refs_failed`, `tool_refs_rejected`), resolves relative files against `TurnContext#cwd` (else
280
+ the process's), drops a ref whose spelling **or** real path is outside it, stores a kept one under its real path
281
+ (`File.realpath`), and caps at `RowRefs::MAX_REFS` / `MAX_BYTES`. A reload has no plugins, so the run saves them as
282
+ `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent) and `MessageParts#tool_part` re-checks them
283
+ (`RowRefs.for_display`).
284
+ - An unknown tool gets `ToolSurface#unknown_tool_error` ("Error: no such tool ...", never repeating the wrong name).
285
+ - Edit and write: the file is diffed, and a memory's index line refreshed (`MemoryBundle::IndexSync`). At most
286
+ `MAX_IMAGES_PER_RESULT` images per result. Non-UTF-8 bytes are scrubbed.
287
+ - The output cap (`max_tool_output_chars`, a `memory_read`'s `memory.read_max_chars`) cuts by the tool's
288
+ `ToolOutputCut` policy; an execute's whole output is saved by `ToolOutputFiles` in `TurnContext#session_dir`. See
289
+ [tool-output-limits.md](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model, the event and the
290
+ after_tool_call hook all get the cut text; `output_file` is on the run and the event, never in the session.
291
+ - **The activity's status** (`ToolActivity#tool_activity_status`): `blocked` for a gate denial
292
+ (`ToolRunner#denied`); `stopped` for a call a Stop or signal left unrun (`Tools::Execute::NOT_RUN_ON_STOP`,
293
+ `NOT_RUN`), an execute it killed (`STOPPED_BY_USER`, `STOPPED_BY`) or a `task_wait` whose header says canceled or
294
+ stopped; `ok` for an execute handed over as a task (`#handed_over?`); `error` for any other `Error:` result, a
295
+ failed `task_wait`, and an execute that exited non-zero, **except** a grep-family search that found nothing
296
+ (`ToolActivity.no_match?`, conservative: when unsure, not a no-match), which stays `ok` with `no_match: true`
297
+ (kept by SessionMetrics and the TurnAccumulator for the web's "no match" word). The model's text is unchanged.
298
+
299
+ The two events' payloads are in "Events" below.
300
+
301
+ ### ToolSurface
302
+
303
+ `Tools::ToolSurface` (`tools/tool_surface.rb`): the turn's tools as **one frozen value**, built once in
304
+ `Engine#generate` (`ToolSurface.for(registry, layers:, loads_tools:)`) into `TurnContext#surface`. The Registry is its
305
+ only input (`entries`, `reachable_names`, `left_out_names`, `removed_names`, `stranded_names`). It holds `declared`
306
+ (prompt order), `index_only` (reached by `call_tool`), `left_out` (`{name => :indexed | :user_only}`), `removed`,
307
+ `stranded`, `registered`, `toolkit`, `reaching_kit`, `index`, `layers`, `loads_tools`, and answers `entry_for(call)` (a `via:` call only from
308
+ `index_only`; a direct one from `declared`, or `index_only` under `loads_tools`), `unknown_tool_error`, `schemas` and
309
+ `deferred_for(conversation)` (the index's source tools are asked per request, so a late MCP server joins mid-turn).
310
+
311
+ - **Who reads it**: `KernelLoop#dispatch`, `ChatLoop#tool_definitions` / `#deferred_tools` (whether to append
312
+ deferred ones is still the adapter's `#loads_tools?` per request: a refusal turns it off mid-turn), the runner's
313
+ wrap / unwrap / left-out / group-refusal checks, `find_tools` and `call_tool` (`KernelLoop::ToolContext#surface`),
314
+ and the gate's and the row's `call_targets` / `call_preview` (an entry's `preview` / `targets` take
315
+ `(call, surface)`; `Registry#register` wraps a one-argument one).
316
+ - **A change mid-turn** (`/toolkit`, a staged `chi.replace_tools`) shapes the next turn's surface only.
317
+ - **Fail closed**: a context without a surface raises `Tools::MissingSurface` (`ToolSurface.of`); the `targets:` /
318
+ `preview:` rescues and the runner's unwrap re-raise it, so the call never reaches the gate. A bare kernel's or
319
+ loop's own context builds one (`ToolSurface.bare`: `KernelLoop#own_context`, `ChatLoop#turn_context`).
320
+ - **Outside a turn's context**: on the turn thread, built from the Registry (`ToolSurface.for`: the prompt, cached
321
+ per build before `generate` knows `loads_tools`, `SystemPrompt::Built#surface`; `#warn_stranded`; the
322
+ suggestion's lists; the index's `#declare` / `#refresh!`). Off the turn thread, from `ToolkitBinding#published`
323
+ only (`#explained` for `/toolkit` and the snapshot, `ctx.load_tools`).
386
324
 
387
325
  ## State management
388
326
 
389
327
  ### TurnState (cross-thread)
390
328
 
391
- One object, guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`) and other threads
392
- read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
393
-
394
- ```
395
- TurnState
396
- ├── running?
397
- ├── controller (the running turn's CancellationController)
398
- ├── thread, ended (the turn's own thread; its end event is out: post-turn work)
399
- ├── sink (the turn's on_event)
400
- ├── steers (plugin steers for the running turn's next boundary)
401
- ├── carried_steers (for the next turn that begins)
402
- └── activity clock (last_activity_at, activity_seq)
403
- ```
404
-
405
- - `begin!(controller:, sink:)`: running, with controller and sink (and the calling thread, the turn's own), in one
406
- step; carried steers become steers.
407
- - `ended!`: the turn's end is decided (`end_turn`, next to `turn.ended`): its after_turn / session_end hooks run
408
- after it and before `finish!`.
409
- - `own_controller(handler:)`: the controller for work done for the turn itself, which a plugin's `ctx.http` call
410
- without `cancel:` stops with (`Plugin::Host#cancel_controller`, `Engine#plugin_cancel_controller`): the turn's, on
411
- its thread or inside a plugin handler, until `ended!`; nil elsewhere (an anytime command, a service, a plugin's
412
- own thread) and outside a turn. An init task's own controller wins inside one.
413
- - `finish!`: not running, controller, thread and sink cleared; returns the steers never taken (logged as dropped).
414
- - `steer(text, source:)`: queues a steer for the running turn (false with no turn).
415
- - `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
416
-
417
- The lock is a leaf: nothing is called out while it is held.
329
+ One object (`turn_state.rb`), guarded by a `Monitor`, that the turn thread writes (`begin_turn`, `release_turn`)
330
+ and other threads read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs. It holds
331
+ running, `context` (nil between turns), `last_context`, `controller`, the turn's thread and `ended`, the sink,
332
+ steers and carried steers, and the activity clock.
333
+
334
+ - `begin!(context:, controller:, sink:, input_waiting:)`: all of it in one step, carried steers become steers. The
335
+ context is built whole before (mode and git included), **so no reader sees a running turn with the last turn's
336
+ author**.
337
+ - `replace_context(context)`: the turn thread's new frozen context (`generate`'s settings); refused from another
338
+ thread or for another turn's id.
339
+ - `ended!`: the end is decided (`end_turn`); the after_turn / session_end hooks run after it, before `finish!`.
340
+ - `own_controller(handler:)`: the controller a plugin's `ctx.http` without `cancel:` stops with
341
+ (`Plugin::Host#cancel_controller`): the turn's, on its thread or inside a plugin handler, until `ended!`; nil
342
+ elsewhere (an anytime command, a service, a plugin's own thread). An init task's own controller wins inside one.
343
+ - `finish!`: everything cleared, the context kept as `last_context` (with the last settings when it never reached
344
+ `generate`); returns the steers never taken.
345
+ - `steer(text, source:)` (false with no turn), `steer_next_turn(text, source:)`.
346
+
347
+ **The lock is a leaf: nothing is called out while it is held.**
348
+
349
+ **Reading the turn from another thread.** A thread off the turn (a Bridge request, an anytime command, a plugin's
350
+ thread, a hook fired by one) reads the running turn **only** through `Engine#turn_context` and
351
+ `Engine#last_turn_context`, one read each, never through the kernel, `GuardrailWiring` or an Engine ivar. The
352
+ readers that do: `Engine#turn_author`, `#side_author`, `EventFanout#working_set_llm_context` and `#live_conversation`
353
+ (nil unless the loop's `LLMContextForget::Turn#turn_id` is the running turn's). The turn's own code takes the
354
+ context as an argument. The turn stages' reads off the turn: ContextJudge's asks thread reads `current_session`
355
+ (`@session`) and shows a card through `show_card` (`Engine#show_card`, whose route `EventRoutes` reads on that thread),
356
+ and asks `TurnState#running?`; `announce_objectives` from a Bridge, command or Check this thread reads
357
+ `live_conversation` and `session_state_dir` under the event lock. Cancel stays `TurnState#controller` (a Stop never reads `context.controller`).
358
+ `spec/turn_context_readers_spec.rb` reads each the moment a turn is published. The tool Registry likewise: off the
359
+ turn thread it is `ToolkitBinding#published`, a frozen `Registry#snapshot` swapped at the Engine's build, after
360
+ `apply_staged_tools!` and after `apply_toolkit!`.
418
361
 
419
362
  ### EffectiveModel (a snapshot per read)
420
363
 
421
- `EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
422
- sent) with its lookup names, and reads each setting from them (sampling, thinking, reasoning history, steer cut,
423
- LLM context, window, price, served model, vision, profile). It is built per read and never kept: config.yml is
424
- read live, a model listing can move a bare id to another host, and another process's saved model lists change
425
- what a resolve gives, all without a `/model`. Frozen, so any thread may hold one.
426
-
427
- - The Engine's readers take one (`Engine#effective_model`); those whose fallback always covered the resolve go
428
- through `SessionModelSettings#model_setting` (`session_model_settings.rb`: the Engine forwards its readers of the session's settings there). A turn takes one in `turn_settings` (before the hooks) and one in `generate` (after
429
- them); `system_prompt`, `steer_cut_after` (at `:turn_started`) and the turn-end warm-up take their own;
430
- `stats_snapshot` and `session_state_snapshot` take one each (the latter nil-tolerant; both `EngineSnapshots`',
431
- `engine_snapshots.rb`, which the Engine forwards to).
432
- - Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`,
433
- `guardrail_model_name`, `sync_model_key!`, `served_model(probe: false)` (`HostRegistry#model_ref`/`#bare_name`).
364
+ `EffectiveModel.of(registry, choice)` (`effective_model.rb`) resolves the model once (`HostRegistry#resolve`) and
365
+ reads each setting from it (sampling, thinking, reasoning history, steer cut, LLM context, window, price, served
366
+ model, vision, profile). **It is built per read and never kept**: config.yml is read live, a model listing can move
367
+ a bare id to another host, and another process's saved model lists change what a resolve gives, all without a
368
+ `/model`. Frozen, so any thread may hold one.
369
+
370
+ - The Engine's readers take one (`Engine#effective_model`); those with a fallback go through
371
+ `SessionModelSettings#model_setting` (`session_model_settings.rb`). A turn takes one in `turn_settings` (before
372
+ the hooks) and one in `generate` (after them); `system_prompt`, `steer_cut_after` and the warm-up take their own;
373
+ `stats_snapshot` and `session_state_snapshot` (`EngineSnapshots`, `engine_snapshots.rb`) one each.
374
+ - Never resolved (they hold on a broken resolve): `effective_model_ref`, `bare_model_name`, `guardrail_model_name`,
375
+ `sync_model_key!`, `served_model(probe: false)`.
434
376
  - Readers on other threads: the Bridge (`/state`, `/stats`, the SSE reset, a steer's cut wait), the worker's
435
- command thread, the recap thread and plugin threads (`session_model_recap_target`).
436
- - `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as one value, so a reader on another
437
- thread never pairs one model's name with another's typed name.
377
+ command thread, the recap thread, plugin threads.
378
+ - `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as **one value**, so a reader never pairs
379
+ one model's name with another's typed name.
438
380
  - `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
439
381
 
440
- `SessionSetting` names the four per-session settings (llm_context, thinking, reasoning_history, toolkit): the
441
- session's own value, the commands that move each chip, its chip from an EffectiveModel or from the Engine's
442
- readers, a saved session's chip, and how the web takes a worker snapshot's. The session state, `/stats` (no
443
- toolkit), a `:command_ran` and the web's session JSON list them from it.
382
+ `SessionSetting` (`session_setting.rb`) describes the four per-session settings (llm_context, thinking,
383
+ reasoning_history, toolkit): own value, commands, chips, a saved session's chip, the web's fallback. The session
384
+ state, `/stats` (no toolkit), `:command_ran` and the web's session JSON list them from it.
444
385
 
445
386
  ### ModelBinding (the choice and its profile)
446
387
 
447
- `ModelBinding` (`model_binding.rb`) holds the session's model choice, the profile given to `Engine.new` (until a
448
- switch), the prompt profile resolved for the choice (kept as a `[choice, resolution]` pair) and the memory overlay
449
- keys (`#sync_model_key!`), under one mutex; it is ToolkitBinding's counterpart, and the Engine forwards
450
- `profile_resolution`, `refresh_profile!`, `store_profile`, `apply_profile`, `resolve_profile`, `sync_model_key!`
451
- and `model_key` to it.
452
-
453
- - `#switch!` (`Engine#switch_model!`'s swap) is one assignment under the mutex: a reader on another thread sees
454
- the old pair or the new one. It drops the given profile and the kept resolution.
455
- - `#store_profile` keeps and applies a resolution only while the choice is still the one it was resolved for,
456
- by identity (`equal?`, not `==`): a `/stats` probing on a Bridge thread while `/model` runs keeps nothing.
457
- - `#apply_profile` runs under the mutex: the system prompts built for another profile are dropped, then
458
- `KernelLoop#use_profile!`. Nothing else takes the mutex.
459
-
460
- ### ThinkingNotices
461
-
462
- What a turn's thinking level leads to, said once per session and host (`thinking_notices.rb`): an effort the
463
- host can't do, off the model didn't honour, a host refusing the thinking fields or the reasoning sent back. It
464
- keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
465
- call it. Turn thread only.
466
-
467
- ### Turn (Engine::Turn)
468
-
469
- The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
470
-
471
- ```ruby
472
- Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
473
- :settings, :id, :limit, :resumed, :resume_from) do
474
- def resumed? = resumed ? true : false # a step-limit Continue
475
- def tag(event) = origin ? event.merge(origin: origin.to_h) : event # boundary events carry the origin's wire Hash
476
- def elapsed = ... # seconds since started_at
477
- end
478
- ```
479
-
480
- - `messages` is what the Engine sends: system head, history, reminders, the prompt. The loop works on its own
481
- copy; the Engine keeps `result.conversation` after a normal end or a Stop, and the error's partial
482
- conversation after a failure.
483
- - `controller` is the turn's `CancellationController`, an in-process signal. Another process stops a worker's
484
- turn through the Bridge (`POST cancel` → `Engine#cancel_current_turn!`).
485
- - `on_event` is the turn's sink.
486
- - `ended` is set by `end_turn`.
487
- - The live conversation: both loops register the array they append to on the kernel
488
- (`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`; `KernelLoop#start_turn`,
489
- `ChatLoop::Run#initialize`). `Engine#live_conversation` returns it while a turn runs (nil otherwise: the kernel
490
- never clears it). A worker's drain (`Worker#pending_input_drain`, on the turn's thread at every iteration
491
- boundary) copies it, with the lines that boundary merges, into `Session#turn_checkpoint` (`TurnCheckpoint`):
492
- every save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's
493
- messages, written at the first boundary and then at most every 10 s, so a killed worker loses at most that much
494
- of its turn. `session.messages` itself is untouched until the turn ends. When the checkpoint writes, the worker
495
- also calls `Engine#checkpoint_metrics` (`SessionMetrics#persist(running_turn: true)`): `analytics.json` gets the
496
- running turn's record so far as a killed turn's (canceled, `worker_stopped`), which the next worker loads; the
497
- turn's end writes over it.
388
+ `ModelBinding` (`model_binding.rb`) holds the model choice, the profile given to `Engine.new` (until a switch), the
389
+ prompt profile resolved for the choice (a `[choice, resolution]` pair) and the memory overlay keys, under one mutex.
390
+ The Engine forwards `profile_resolution`, `refresh_profile!`, `store_profile`, `apply_profile`, `resolve_profile`,
391
+ `sync_model_key!` and `model_key` to it.
392
+
393
+ - `#switch!` is one assignment under the mutex: a reader sees the old pair or the new one. It drops the given
394
+ profile and the kept resolution.
395
+ - `#store_profile` keeps a resolution only while the choice is still the one it was resolved for, **by identity**
396
+ (`equal?`): a `/stats` probing on a Bridge thread while `/model` runs keeps nothing.
397
+ - `#apply_profile` runs under the mutex (prompts for another profile dropped, then `KernelLoop#use_profile!`).
398
+ Nothing else takes the mutex.
399
+
400
+ `ThinkingNotices` (`thinking_notices.rb`) says once per session and host what a turn's thinking level leads to (an
401
+ effort the host can't do, an off not honoured, a host refusing the thinking fields or the reasoning sent back).
402
+ Turn thread only.
403
+
404
+ ### Turn and TurnContext
405
+
406
+ The `TurnContext` (`turn_context.rb`, a frozen `Data.define`; `TurnContext.none` for a bare kernel, a spec, the
407
+ bundle test kit) holds what the tool path reads of a turn; `Engine::Turn` keeps only lifecycle state. The
408
+ KernelLoop has its own `Turn` struct, a run's.
409
+
410
+ - **Nil until `generate`**: `settings` (`LLM::TurnSettings`), `model_key` / `fallback_model_key` (memory overlays),
411
+ `model_name` (the gate's rules' `models:`) and `surface`. They are set after the `before_turn` hook that may switch
412
+ the model. `turn.prepared_settings` (before the hooks) give vision, sampling and thinking, so a hook's switch gets
413
+ the old model's for those.
414
+ - The live bits are objects it holds, not copies: `session` (working-set pins read at call time), `controller`,
415
+ `git` (`Guardrails::GitInfo`, the per-turn git cache).
416
+ - **Down the tool path it is an argument**: `ModelBackend#complete(context:)` → `KernelLoop#run` / `ChatLoop::Run` →
417
+ `ToolResponse.run_batch` → `ToolRunner#run` → `Gate#evaluate` / `#settle_ask` and `KernelLoop#dispatch_tool_call`
418
+ → `KernelLoop::ToolContext.new(kernel, context)`. The generation guard gets it too
419
+ (`KernelLoop::Services#generation_guard`, `guard_participant_spend`).
420
+ - The kernel's own `context` / `turn_settings` and `ChatLoop#turn_context` are the turn thread's working copies
421
+ (after a chat turn the kernel's still hold the last native run's): **a reader outside the turn uses
422
+ `Engine#turn_context` / `#last_turn_context`**.
423
+ - What lasts the Engine's life is not in it: `KernelLoop::Services` (hooks, tools, reminder store, mutes, the
424
+ question handler, peers, the gate, warm-up, the generation guard), one frozen value given at
425
+ `KernelLoop.new(services:)` or `#attach`. **No turn field goes in Services, no Engine-lifetime one in
426
+ TurnContext.**
427
+ - Specs: `run_context(settings: …)` and `attach_services(kernel, tools: …)` (spec/support/test_kernel.rb).
428
+ - `controller` is an in-process signal; another process stops a worker's turn through the Bridge (`POST cancel` →
429
+ `Engine#cancel_current_turn!`).
430
+
431
+ **The live conversation and the checkpoint.** Both loops register the array they append to on the kernel
432
+ (`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`, tagged with the run's `turn_id`).
433
+ `Engine#live_conversation` returns it only while a turn runs and once its loop registered it (the kernel never
434
+ clears it; the turn id tells the last turn's from this one's). A worker's drain (`Worker#pending_input_drain`, at
435
+ every iteration boundary) copies it, with the merged lines, into `Session#turn_checkpoint` (`TurnCheckpoint`): every
436
+ save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's messages, at the
437
+ first boundary and then at most every `TurnCheckpoint::INTERVAL`. `session.messages` itself is untouched until the
438
+ turn ends. The worker then also calls `Engine#checkpoint_metrics`, so `analytics.json` has the running turn's record
439
+ as a killed turn's (canceled, `worker_stopped`); the turn's end writes over it.
498
440
 
499
441
  ### SteerCut, GenerationPhase and WaitingSteer
500
442
 
501
- The Engine holds one `SteerCut` (`@steer_cut`), which owns a `GenerationPhase` and a `WaitingSteer`: the Bridge and
502
- the TUI ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the Engine's stream handler feeds it
443
+ The Engine's one `SteerCut` (`steer_cut.rb`) owns a `GenerationPhase` and a `WaitingSteer`. The Bridge and the TUI
444
+ ask it (`Engine#cut_for_steer`, `#input_epoch`, `#generation_snapshot`), the stream handler feeds it
503
445
  (`SteerCut#observe`), a drain that took input calls `#delivered!`, and `release_turn` calls `#finished!`.
504
446
 
505
- `GenerationPhase` is what the running generation streams, fed through `SteerCut#observe`: whether a steer sent as a
506
- cut (delivery `cut`: `/cut TEXT`, `chi send --cut`) may cut it. `SteerCut#cut_for_steer` answers `:now`, `:waits` or `:off`; the
507
- Bridge acks it as `cut`.
508
- A generation is cuttable while it has streamed only thinking, is still thinking, and has thought for at least
509
- the model's cut wait (`SteerCutAfter.resolve`: `steer_cut_after` per models:/hosts: entry, else `steer.cut_after`) seconds (`cuttable?(min_age)`). It has `started!`, `chunk!`, `retrying!` and `finished!`.
510
- `snapshot` is the running generation as `GenerationPhase::Snapshot` `{thinking_only, age}` (nil with none running;
511
- `age`: its thinking's seconds, nil before any): `SteerCut#generation_snapshot` → `Engine#generation_snapshot`, which
512
- `Bridge#snapshot` merges into `current_turn.generation`, so a web page joining mid-generation can clock its delivery
513
- line and tell whether "cut in now" can land.
514
-
515
- ### WaitingSteer
516
-
517
- A message that may cut the generation but came too early (the generation hadn't been thinking long enough). It
518
- waits until the thinking passes the model's cut wait (`SteerCut#observe` rechecks it on each thinking chunk), until a
519
- drain hands the message to the model at a boundary, or until the turn ends (`SteerCut#finished!` clears it).
447
+ - `GenerationPhase`: whether a steer sent as a cut (`/cut TEXT`, `chi send --cut`) may cut the running generation.
448
+ `cut_for_steer` answers `:now`, `:waits` or `:off`. A generation is cuttable while it has streamed only thinking,
449
+ is still thinking, and has thought for at least the model's cut wait (`SteerCutAfter.resolve`). Its `snapshot`
450
+ (`{thinking_only, age}`) goes into the Bridge's `current_turn.generation`, so a page joining mid-generation can
451
+ tell whether "cut in now" can land.
452
+ - `WaitingSteer`: a cut that came too early. It waits until the thinking passes the cut wait (rechecked on each
453
+ thinking chunk), until a drain hands it to the model at a boundary, or until the turn ends.
520
454
 
521
455
  ## Events
522
456
 
523
457
  ### The turn's sink (`on_event`) and the observers
524
458
 
525
459
  `Engine#emit_event` (`EventFanout#emit_event`, `event_fanout.rb`, which also builds the loops' stream handler and
526
- emits the working set) sends each turn event to the turn's sink (`on_event`, a one-shot's renderer) and then to the
527
- persistent observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`. The observers are the
528
- metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
529
- TUI. Errors in the sink or an observer are caught; they never break the turn.
460
+ emits the working set) sends each turn event to the turn's sink (a one-shot's renderer) and then to the persistent
461
+ observers (`Engine#subscribe`), which get a copy with a monotonic `event_seq`: the metrics, the debug log, the
462
+ thinking tails, and in a worker the Bridge ([bridge.md](bridge.md)). **Errors in the sink or an observer are
463
+ caught; they never break the turn.**
530
464
 
531
- `Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `turn_dequeued`
532
- (a queued message taken back before it ran, `{enqueued_id, client_id}`: the remover's), `card`,
533
- `hook_notice`, `guardrail_warning`, `plugin_init_started`, `participants_changed` (`{participants, joined, left}`:
534
- who is in the review now and who was invited, revoked or expired since the worker last looked; `ParticipantsWatch`,
535
- [participants](participants.md#the-owners-terminal)), and more) to the observers only. `:side_usage`
536
- (`{kind, side}`: a side request's usage, the recap's, `/btw`'s or `ask_model`'s, and the session's side sums) goes
537
- to the observers the same way from `Engine#note_side_usage` (any thread), without being announceable.
465
+ `Engine#announce` puts an event that belongs to no turn (`Engine::ANNOUNCEABLE_EVENTS`: `turn_enqueued`,
466
+ `turn_dequeued` (`{enqueued_id, client_id}`: the remover's), `card`, `hook_notice`, `guardrail_warning`, `participants_changed`
467
+ ([participants](participants.md#the-owners-terminal)), and more) to the observers only. `:side_usage` (a side
468
+ request's usage and the session's side sums) goes to the observers the same way from `Engine#note_side_usage` (any
469
+ thread), without being announceable.
538
470
 
539
- Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
471
+ Turn events (the main ones; `events.rb` keeps the shared sets):
540
472
 
541
473
  | Event | When | Payload |
542
474
  |---|---|---|
543
- | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}` (`resumed`: a step-limit Continue; `at`: the turn's wall time, its prompt's stamp) |
544
- | `:reminder_injected` | due reminders went into the turn | `{reminders, at}` (`at`: the turn's wall time, the reminder message's stamp) |
545
- | `:context_status` | every request (unless `context.status` is off), the current estimate before it; and outside any turn after `/model` (or when a session is loaded on another model than its last turn ran on), to the observers only (`Engine#announce_switched_context!`) | `{iteration, status, usage, bucket, source}`; the switch's form has no `iteration`: `{usage, bucket, context_window_tokens, context_window_source}`, with `usage.source: "switch"`, and `usage` nil when the new window doesn't resolve (internals/context-telemetry.md) |
546
- | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`price`: the turn's `ModelPrice#to_h`, nil without one; `model_ref`: the turn's model, asked `host:model`, added by the Engine's stream handler from the model `#generate` resolved after the `before_turn` hooks, so a hook's switch is the one named; native adds `profile`, `profile_source`) |
547
- | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection instead: `{iteration, text, thinking, tool_call?, counts?}`, architecture.md) |
548
- | `:generation_retrying` | the transport retries the request, or the chat loop asks again for a step whose stream dropped mid-answer (`restarted: true`: what the step streamed is void, and the web's live step, the TUI's activity line, the Bridge's TurnAccumulator and the stream hooks' watch start it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
475
+ | `:turn_started` | `prepare_turn` | `{session_id, prompt, turn_id, at, continue?, resumed?, images?, cut_after}`, plus `author` / `author_name` / `participant_mode` for a participant |
476
+ | `:reminder_injected` | due reminders went in | `{reminders, at}` |
477
+ | `:context_status` | every request (unless `context.status` is off); outside a turn after `/model`, observers only | [context-telemetry.md](context-telemetry.md) |
478
+ | `:generation_started` | a request begins | `{iteration, context_window_tokens, context_window_source, price, model_ref}` (`model_ref` from the model `generate` resolved after the hooks; native adds `profile`, `profile_source`) |
479
+ | `:generation_chunk` | each streamed chunk | `{iteration, content, text, thinking, payload, tool_call?}` (SSE clients get the Bridge's projection) |
480
+ | `:generation_retrying` | a transport retry, or a chat step asked again after its stream dropped (`restarted: true`: what the step streamed is void, and every live view starts it over) | `{iteration, attempt, max_retries, next_delay, error_class, error_message, status, restarted?}` |
549
481
  | `:generation_completed` | a generation ended, or was cut | `{iteration, content_length, thinking_chars, served_model, requested_model, finish_reason, ...}`; a cut adds `stopped_by`, `stop_reason`; the Engine adds `speed`, `tokens`, `model_segments` |
550
- | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
551
- | `:thinking_dropped` | the chat loop settled a preserved-thinking recovery: the adapter dropped replayed thinking blocks and they left the history (`ChatLoop#settle_dropped_thinking!`; the step's `:generation_completed` carries `thinking_dropped`; internals/anthropic.md) | `{iteration, count, how}` (`how`: `:drop_block` or `:strip`) |
552
- | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
482
+ | `:generation_cancelled` | cancelled mid-loop | `{iteration, reason, stopped_by}` |
483
+ | `:thinking_dropped` | the chat loop dropped replayed thinking blocks (`ChatLoop#settle_dropped_thinking!`; anthropic.md) | `{iteration, count, how}` |
484
+ | `:empty_answer_retry` | an empty, cut or malformed answer asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
553
485
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
554
486
  | `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
555
- | `:thinking_refused` / `:reasoning_history_refused` | the chat loop asked a step again without the thinking fields / the reasoning sent back; the Engine logs it and makes a once-per-session-and-host `:hook_notice` of it, the event itself never reaches the sink | `{iteration, model, level, detail}` |
487
+ | `:thinking_refused` / `:reasoning_history_refused` | the chat loop's fallbacks; logged and made a once-per-session-and-host `:hook_notice`, **never reaching the sink** | `{iteration, model, level, detail}` |
556
488
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
557
- | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
558
- | `:tool_call_started` | before a call runs | Engine event includes `call` (see ToolRunner); the Bridge's SSE frame omits it |
559
- | `:tool_call_completed` | after a call ran | see ToolRunner |
560
- | `:question_requested` / `:question_answered` / `:question_cancelled` | `QuestionDesk`: a question is asked (an approval, `ask_user_question`, a hook's or plugin's `ask_user`, the step-limit Continue), answered, or withdrawn | `{pending_question}` / `{id, answer}` / `{id, reason}` |
561
- | `:plugin_init_wait` | a turn waits for plugin init tasks that provide tools (`PluginTasks`) | `{tasks: [{bundle, id, label}]}` |
489
+ | `:tool_dispatch_started` / `_completed` | around a batch | `{iteration, call_count}` |
490
+ | `:tool_call_started` | before a call runs | `{iteration, call_count, call_index, tool, call, params, label, title, called_as, via, view, ref, refs}`; the SSE frame omits `call` |
491
+ | `:tool_call_completed` | after a call ran | `{iteration, call_count, call_index, tool, output, output_truncated, output_file, activity, images, diff, waited_ms, view, ref, refs}` |
492
+ | `:question_requested` / `_answered` / `_cancelled` | `QuestionDesk` (an approval, `ask_user_question`, a hook's `ask_user`, the step-limit Continue) | `{pending_question}` / `{id, answer}` / `{id, reason}` |
493
+ | `:plugin_init_wait` | a turn waits for init tasks that provide tools (`PluginTasks`) | `{tasks: [{bundle, id, label}]}` |
562
494
  | `:used_memories_updated` | a memory was read, and after the loop | `{used_memory_names, read_names?}` |
563
- | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty, except one empty event when the last one had items (a rollback emptied the set): a tool-free turn emits none (`Engine#emit_working_set`); between turns `Engine#announce_working_set` announces it under the next turn's strategy (after a `/working-set` op, held behind the command's `command_ran`) | `{items: [{kind, key, state (live\|stale\|forgotten\|dropped), tokens, ops, count, first_id, pinned, has_drop}], at, adds?: [{kind, key, label}]}` (`WorkingSet`, internals/working-set.md; `adds` while the user's adds are pending) |
564
- | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
565
- | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
566
- | `:turn_completed` | normal end | Engine event `{turn_id, result, turn_summary, display_pending}`, plus `verify: true` (a Verify turn, verdict or not: the page's `data-verify`), `verdict` (a Verify turn's recorded verdict) and `revises` (a Revise turn's stamp, `{turn_id}`) when there are; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
567
- | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{turn_id, cancellation_reason, cancelled_by?, duration_ms}` |
495
+ | `:working_set_updated` | after `:generation_started` and once in `end_turn`, only when the set changed (`Engine#emit_working_set`) | [working-set.md](working-set.md) |
496
+ | `:hook_notice` | a hook's or plugin's notify | `{hook, text, level}` (`between_turns: true` outside a turn) |
497
+ | `:guardrail_warning` | a guardrails line | `{message}` |
498
+ | `:turn_completed` | normal end | `{turn_id, result, turn_summary, display_pending}`, plus `verify`, `verdict`, `revises` when there are; the SSE frame omits `result` |
499
+ | `:turn_canceled` | Stop, hook stop, Ctrl-C, signal | `{turn_id, cancellation_reason, cancelled_by?, duration_ms}` |
568
500
  | `:turn_failed` | an error | `{turn_id, error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
569
- | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
570
-
571
- A turn's origin is a `TurnOrigin` (`lib/samagotchi/turn_origin.rb`, a `Data.define` of `client_id`, `enqueued_id`
572
- and `author`): `SessionInbox.read_input` makes it from an input file's own fields, the wakes, reports and reminders
573
- make theirs, and `Engine#run_turn`, the Worker and `GuardrailWiring` pass it on. Its readers say what callers ask
574
- (`#participant?`, `#participant_author`, `#participant_id`, `#source` = `ClientId.origin`, `#human?` =
575
- `ClientId.human?`). At a boundary it is turned once: `#to_h` writes the wire Hash (only the fields set, as before),
576
- `TurnOrigin.from` reads a Hash back (symbol or string keys; nil for none). In-process an origin is never nil:
577
- `TurnOrigin.of` gives `TurnOrigin::NONE` (no sender: an owner's turn with no client) for nil or `{}`, and NONE is
578
- `run_turn(origin:)`'s default (a Hash is still read as one). NONE has no wire form (`#empty?`; `#wire` is nil, or
579
- `#to_h` for any other origin), so the events and the hooks' `context` carry no origin for it, as before.
580
- `LastTurn#author` stays the session file's string; `LastTurn#participant_author` reads it as
581
- `TurnOrigin#participant_author` does. The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry
582
- `origin` when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than
583
- the session's owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`).
584
- An owner's turn has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn`
585
- gets the origin, and `#turn_author` reads its participant author for the gate's checks; the guardrails `Context`
586
- (the hooks' `context`) keeps the wire Hash.
587
-
588
- Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any author but none or `"owner"`) goes:
589
-
590
- - **The opening message**: `{role: "user", content:, turn_id:, author:, author_name:}` (`Engine#author_fields`;
591
- the name from `participants.json` at the turn's start, `SessionParticipants`, left out when the sidecar doesn't
592
- name the participant). Merged input and steers carry the same two keys (`Steer::Line#author`, grouped by sender
593
- and author so a participant's lines never share the owner's message). Assistant and tool messages carry none:
594
- `Steer.author_at(messages, index)` resolves the turn's author by position. `ChatLoop#plain` keeps the keys
595
- (`Steer::AUTHOR_KEYS`).
596
- - **The wire**: such a message goes out under the review-contribution header (`Steer.header` /
597
- `Steer.review_header`, both loops through `Steer.wire_text`; see prompt-caching.md). Saved raw.
598
- - **`Session#last_turn.author`** (`LastTurn#origin` stays the category) and the turn's analytics record's `author`
599
- (`SessionMetrics`).
600
- - **Queued turns**: `turn_enqueued`; the accumulator's queued rows. A participant's `/word` or `!line` is never a
601
- command: `Bridge#handle_post_turn` answers it 403 and `WorkerInbound#queue_as_command` drops it (so
602
- `QueuedCommand#author` stays nil on every path now).
603
- - **Delivery**: a participant's input file always waits for a turn of its own (`SessionInbox.waits_for_turn_end?`),
604
- so `Worker#pending_input_drain` never merges it; the Bridge queues their `POST /turn` whatever its delivery and
605
- counts their running and queued turns against chi web's `pending_limit` (429 `too_many_turns`).
606
- `Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
607
- `SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
608
- keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
609
- - **The gate**: `GuardrailWiring#participant_boundary` is the core check after the load failures, picked by
610
- `#turn_mode`: `Guardrails::PairBoundary` for a pair turn, `Guardrails::ReviewBoundary` for a review one. Both read
611
- `#turn_author` and are no-ops for the owner's turns; the review boundary denies a participant's turn everything
612
- outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
613
- `ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
614
- The declared tools stay the session's (D3: no rebuild per author).
615
- - **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise) is the hook
616
- runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
617
- plugin host's (`ctx.turn_author`).
618
- - **Spend**: the analytics turn record's `author` feeds `SessionMetrics#spend` / `spend_by_author` (the snapshot's
619
- `spend_by_author`, `/stats`); `Engine#note_side_usage` stamps a side request's author (`#side_author`: the running
620
- turn's, the recap's after it, else the owner's) into `SideTotals#by_author`.
501
+ | `:answer_display` | after the after_turn hooks (observers only) | `{display, refs?}` |
502
+
503
+ ### Origins and authors
504
+
505
+ A turn's origin is a `TurnOrigin` (`turn_origin.rb`, `client_id`, `enqueued_id`, `author`). `SessionInbox.read_input`
506
+ makes it from an input file; the wakes, reports and reminders make theirs. Ask it, don't read fields
507
+ (`#participant?`, `#participant_author`, `#participant_id`, `#source`, `#human?`). At a boundary it is turned once:
508
+ `#to_h` writes the wire Hash (only the fields set), `TurnOrigin.from` reads one back. **In-process an origin is never
509
+ nil**: `TurnOrigin.of` gives `TurnOrigin::NONE` (an owner's turn with no client) for nil or `{}`; NONE has no wire
510
+ form (`#empty?`, `#wire` nil), so events and hooks carry no origin for it. The boundary events (`:turn_started`,
511
+ `:turn_completed`, `:turn_canceled`, `:turn_failed`) carry `origin` when there is one (`Turn#tag`), with `author:`
512
+ when someone other than the owner wrote it (stamped by chi web from the request's `Web::Principal`). **An owner's turn
513
+ has no `author` anywhere**: not in its input file, its origin or the session.
514
+
515
+ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`) goes (the model is in
516
+ [participants.md](participants.md)):
517
+
518
+ - **The opening message** gets `author` and `author_name` (`Engine#author_fields`; the name from
519
+ `participants.json` at the turn's start, left out when unnamed). Merged input and steers carry the same keys,
520
+ grouped by sender and author so a participant's lines never share the owner's message (`Steer::Line#author`).
521
+ Assistant and tool messages carry none: `Steer.author_at(messages, index)` resolves by position. `ChatLoop#plain`
522
+ keeps the keys (`Steer::AUTHOR_KEYS`).
523
+ - **The wire**: such a message goes out under the review-contribution header (`Steer.wire_text`; prompt-caching.md).
524
+ Saved raw.
525
+ - **`Session#last_turn.author`** and the analytics record's `author` (`SessionMetrics`); a Continue keeps the offer
526
+ turn's author (`ContinueOffer#run_continue_turn`).
527
+ - **Never a command**: a participant's `/word` or `!line` is refused (`Bridge#handle_post_turn` 403,
528
+ `WorkerInbound#queue_as_command` drops it).
529
+ - **Delivery**: a participant's input always waits for a turn of its own (`SessionInbox.waits_for_turn_end?`), so
530
+ the drain never merges it; their turns count against chi web's `pending_limit` (429 `too_many_turns`).
531
+ `Worker#run_prompt` checks their link and cap (`participant_refusal`) before the turn and after it.
532
+ - **The gate**: `GuardrailWiring#participant_boundary` picks `Guardrails::PairBoundary` or
533
+ `Guardrails::ReviewBoundary` by `TurnContext#participant_mode` (guardrails.md). Both are no-ops for the owner.
534
+ The declared tools stay the session's (no rebuild per author).
535
+ - **Hooks and plugins**: `Engine#turn_author` (the published `TurnContext#author`, so any thread reads it from the
536
+ turn's first moment) is the hook runtime's (`author:` on every event of such a turn) and `ctx.turn_author`.
537
+ - **Spend**: `SessionMetrics#spend` / `spend_by_author`; `Engine#note_side_usage` stamps a side request's author
538
+ (`#side_author`) into `SideTotals#by_author`.
621
539
  - **Side models**: `IdleRecap::TranscriptFilter` and `Plugin::SideQuestion` name a participant's message as theirs.
622
- - **The system prompt**: `Engine#refresh_participants!` at a turn's start (after `apply_toolkit!`) rebuilds the
623
- prompt when the session gains its first active participant or loses its last (the participants line).
540
+ - **The system prompt**: `Engine#refresh_participants!` rebuilds it when the session gains its first active
541
+ participant or loses its last.
624
542
 
625
543
  ### Hook points (not events)
626
544
 
627
545
  `:session_start`, `:before_turn`, `:after_turn` and `:session_end` are hook points: the Engine fires them on the
628
- hook registry (`@hooks.fire`), not on the sink or the observers. The other hook points are `:before_generation`,
629
- `:after_generation`, `:generation_progress` (Hooks::StreamWatch, in batches while the response streams),
630
- `:before_tool_call` (the guardrail gate) and `:after_tool_call` (ToolRunner). docs/hooks.md lists their payloads.
546
+ hook registry (`@hooks.fire`), not on the sink or the observers. The others are `:before_generation`,
547
+ `:after_generation`, `:generation_progress` (`Hooks::StreamWatch`), `:before_tool_call` (the gate) and
548
+ `:after_tool_call` (ToolRunner). docs/hooks.md lists their payloads.
631
549
 
632
550
  ## Session lifecycle
633
551
 
634
- ```
635
- Engine initialization (in this order, among other things):
636
- @turn_state, @steer_cut (SteerCut: its GenerationPhase and WaitingSteer)
637
- @event_fanout (EventFanout: emit_event, the stream handler, the working set; its lookups read at call time)
638
- @turn_endings (TurnEndings: the turn endings' builders)
639
- @host_registry, @model_binding (ModelBinding: the ModelChoice, i.e. the name and the name as typed, and the given
640
- profile or nil: resolved on first need, so building an Engine makes no network call); @model_settings
641
- (SessionModelSettings: the choice and the session read at use); @client = effective_model.client
642
- MemoryBundle::SystemBundle.ensure! (the shipped system bundle installed; a failure is logged, not raised)
643
- @event_routes (EventRoutes: where a card or a notice goes: held while the plugins load, an anytime command's, an init task's, a turn event, else announced or held)
644
- @hook_routes (HookRoutes: notices (through @event_routes), hook questions, stop_turn/stop_generation, steer attribution)
645
- @guardrail_wiring, @question_desk, @relay_desk (RelayDesk: a delegate's approval relay)
646
- @thinking_notices (ThinkingNotices)
647
- build_tools_and_plugins:
648
- @extension_load = ExtensionLoad.new; @hooks = @extension_load.hooks (config.yml's, then the bundles')
649
- @tools = Tools::Builtins.registry; @command_registry (SessionCommands' built-ins); @plugin_tasks
650
- @toolkit_binding (ToolkitBinding: the kits, the plugins' among them, and the turn-start apply)
651
- @tool_index = Tools::ToolIndex.new; @peer_sources = PeerSources.new
652
- the bundles' plugins load (load_plugins → ExtensionLoad#load_plugins; each plugin's ctx is a
653
- Plugin::Context over plugin_host, whose ctx.sessions it builds itself: the test kit passes its own, sessions:),
654
- then (plugins or not) @tool_index.declare(@tools): find_tools + call_tool (source "index", not core),
655
- declared per toolkit by Registry#declared? (a source registered at load, or a tool registered then that
656
- the toolkit leaves reachable: Registry::Index#loaded; Toolkit::Resolved#declares? treats built-ins and
657
- plugin tools alike: full declares both, another kit its list and adds) and
658
- sets call_tool's unwrap (Tools::ToolIndex: the chi group from Registry#reachable_names, the sources'
659
- groups, search, call checks, the unwrap; a source runs the call with its own plugin Context)
660
- @reminder_store = ReminderStore.new (the kernel gets it), @reminder_queue, @reminders
661
- wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
662
- @kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
663
- @hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
664
- @native_backend = LLM::NativeBackend.new(kernel: @kernel)
665
- @prompt_builder = SystemPrompt.new(...)
666
- @session_observer with the metrics, the log subscriber and the thinking tails subscribed (subscribe_session_observers)
667
- @engine_snapshots (EngineSnapshots: session_state_snapshot and stats_snapshot), built last: nothing reads a
668
- snapshot while the Engine is built
669
-
670
- Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
671
- → checks the host (ModelProfile.check_host!), sets the choice (ModelBinding#switch!: name and typed name in
672
- one assignment)
673
- → drops the given profile and the profile resolution (resolved again on first need; one that another
674
- thread finishes for the old model is neither kept nor applied: #store_profile)
675
- → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
676
- → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
677
- → the old model's ctx dropped: @metrics.forget_model_reports! (its window and profile reports) and
678
- @last_context_status = nil; /model (SessionCommands#switch_model) then calls #announce_switched_context!
679
- (the ctx % on the new model's window, before its first turn; Engine#session= does the configured-window-only
680
- variant for a session loaded on another model; internals/context-telemetry.md)
681
- → the kernel is never rebuilt; the next turn's backend follows the new host's api
682
-
683
- Model probes (per turn):
684
- The profile, window, served model and vision are asked from the server's /props as needed.
685
- The cache is dropped at the turn's end, not its start, so one turn doesn't ask twice.
686
- A Stop cuts the probes early (Client.swap_probe_cancel).
687
- ```
552
+ `Engine#initialize` (`engine.rb`, the `build_*` methods) wires the collaborators. Read it for the order; what an
553
+ edit must keep:
554
+
555
+ - **Building an Engine makes no network call**: the profile is resolved on first need (`ModelBinding`).
556
+ - `MemoryBundle::SystemBundle.ensure!` runs at build; a failure is logged, not raised.
557
+ - The turn stages are built before the plugins load (`build_tools_and_plugins`: `ExtensionLoad`, the built-in
558
+ `Tools::Builtins.registry`, `ToolkitBinding`, `Tools::ToolIndex`, `PeerSources`, then the plugins; each plugin's
559
+ `ctx` is a `Plugin::Context` over `plugin_host`). After the plugins (or none), `@tool_index.declare(@tools)`
560
+ registers `find_tools` + `call_tool` (source "index", not core), declared per toolkit by `Registry#declared?`
561
+ (`Toolkit::Resolved#declares?` treats built-ins and plugin tools alike).
562
+ - `wire_kernel` gives the kernel one `KernelLoop::Services` (Engine-lifetime: hooks, tools, reminder store, mutes,
563
+ question handler, peers, gate, warm-up, generation guard); the client is no service (a model switch swaps it,
564
+ `sync_kernel_client!`).
565
+ - `@hooks.runtime = @hook_routes.runtime`: what a hook or plugin does reaches the UIs (`HookRoutes`, built first).
566
+ - `EngineSnapshots` is built last: nothing reads a snapshot while the Engine is built.
567
+
568
+ **Model switching** (`Engine#switch_model!`, `engine/models.rb`): checks the host (`ModelProfile.check_host!`),
569
+ `ModelBinding#switch!` (name and typed name in one assignment, the profile and resolution dropped), `sync_model_key!`,
570
+ `@prompt_builder.reset!`, `sync_kernel_client!`, the context window cache dropped, the old model's context reports
571
+ dropped (`@metrics.forget_model_reports!`); `/model` then calls `#announce_switched_context!`
572
+ (context-telemetry.md). **The kernel is never rebuilt**; the next turn's backend follows the new host's api.
573
+
574
+ **Model probes.** The profile, window, served model and vision are asked from the server's `/props` as needed. The
575
+ cache is dropped at the turn's end, not its start, so one turn doesn't ask twice; a Stop cuts the probes
576
+ (`Client.swap_probe_cancel`).
688
577
 
689
578
  ## Hooks
690
579
 
691
- Plugins and hook files register hooks on hook points (see "Hook points" above, and docs/hooks.md). Every fire
692
- puts the hook runtime on the event: `event[:hook]` (the hook's label) and five helpers, which call the Engine's
693
- `Hooks::Runtime` (built by `HookRoutes`, `hook_routes.rb`, which routes each to the UIs). A plugin's `ctx` has the same ones (`ctx.notify`, `ctx.steer`, ...).
694
- A question takes keywords only at that plugin-facing edge (`event[:ask_user]`, `ctx.ask_user`); from there it is one
695
- `HookQuestion` (`hook_question.rb`: the question, its options, header, freeform flag, the hook's label and its
696
- label => effect map) through `Hooks::Runtime#ask_user` / `Plugin::Host#ask_user`, `Engine#hook_ask_user` and
697
- `HookRoutes#ask_user` to `QuestionDesk#ask_for_hook`, which turns the effects into the pending question's per-option
698
- tags. The bundle test kit records the same value (`Testing::Recorder#questions`).
580
+ Plugins and hook files register hooks on hook points (docs/hooks.md). Every fire puts the hook runtime on the
581
+ event: `event[:hook]` (the label) and five helpers, which call the Engine's `Hooks::Runtime` (built by `HookRoutes`,
582
+ `hook_routes.rb`). A plugin's `ctx` has the same ones. A question takes keywords only at that plugin-facing edge;
583
+ from there it is one `HookQuestion` value through `Engine#hook_ask_user` and `HookRoutes#ask_user` to
584
+ `QuestionDesk#ask_for_hook` (the bundle test kit records the same value, `Testing::Recorder#questions`).
699
585
 
700
586
  | Helper | What it does | Used by |
701
587
  |---|---|---|
702
- | `event[:notify].call(text, level:, fallback_for:)` | One line to the user (`:hook_notice`); `fallback_for: :display` marks a line that stands in for the answer's display, which a UI that renders the display's links leaves out | mcp (a server didn't start), loop-guard |
703
- | `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question through the question flow; nil when there is no one to ask | known-names (a near-miss name: correct it?) |
704
- | `event[:stop_turn].call(reason)` | Cancel the running turn (reason `:hook`), with a warn notice | loop-guard (repeated calls), check-in (`/checkin stop`) |
705
- | `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard (thinking repeats itself) |
706
- | `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in (nudge mode), skills |
588
+ | `event[:notify].call(text, level:, fallback_for:)` | One line to the user (`:hook_notice`); `fallback_for: :display` marks a line standing in for the answer's display | mcp, loop-guard |
589
+ | `event[:ask_user].call(question:, options:, header:, allow_freeform:)` | A single-select question; nil when there is no one to ask | known-names |
590
+ | `event[:stop_turn].call(reason)` | Cancel the running turn (`:hook`), with a warn notice | loop-guard, check-in |
591
+ | `event[:stop_generation].call(reason)` | Cut the streaming generation; the turn goes on | loop-guard |
592
+ | `event[:steer].call(text)` | Queue text for the running turn's next boundary | check-in, skills |
707
593
 
708
594
  From `:after_turn` and `:session_end` there is no turn left: `stop_turn` and `steer` return false.
709
595
 
710
- `:after_turn` adds two of its own, built by `complete_turn` on the stored conversation's last message when it is the
711
- model's: `event[:present]` (`AnswerDisplay`, the answer's `display` text) and `event[:refs]` (`AnswerRefs`, refs kept
712
- as `display_refs`: caps, validation, relative file paths resolved against the session's folder, `refs_rejected`
713
- logged). Both are display only: `AnswerDisplay::DISPLAY_ONLY` lists `display`, `empty_answer` and `display_refs`, which
714
- `strip`/`strip_all` drop from every model-facing copy (hooks' `messages:`, the recap, `ctx.messages`,
715
- after_generation), while `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps
716
- them across turns. `Web::App#messages_for_display` sends `display_refs` re-validated (`AnswerRefs.for_display`).
717
-
718
- History stamps: user prompts, merged input and steers, reminders, context and task notes and the model's replies carry
719
- `at` (`MessageTime::KEY`, `message_time.rb`: a local-offset ISO time with milliseconds), when the entry happened. A
720
- turn reads the wall clock once (`Turn#wall`, `MessageTime.now`): `:turn_started`'s `at`, its prompt's and its
721
- reminders' stamps; `Steer::Merge#messages` stamps a merge at the boundary, `ContextNote.message` a note with its
722
- `created_at` (else now), `KernelLoop#iterate` / `#cancelled_result` and the chat loop's `Run#with_thinking` /
723
- `Run#canceled` each reply. Tool results, `!cmd` messages and TurnNote notes carry none. Like `display`, `at` must
724
- survive the two rebuilds: both model branches of `KernelLoop#sanitize_history` and `ChatLoop#plain` copy it (an
725
- allow-list that drops a key loses it for good on the next turn; `spec/message_time_history_spec.rb` checks both
726
- loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7), but it is not in
727
- `AnswerDisplay::DISPLAY_ONLY`: hooks and `ctx.messages` see it. `:recap_ready` carries the saved recap's
728
- `created_at` (`Engine#emit_recap(created_at:)`, from `IdleRecap#collect`). The UIs' gap dividers come from
729
- `MessageTime.gap_line` (10 min, `GAP_SECONDS`).
730
-
731
- `:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
732
- model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
733
- `AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
734
- (`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
735
- relayed approval (`Engine::RelayPeer#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
736
- fire it. `QuestionAsked.fire` is a no-op without a subscriber (`Registry#any?`); else it finds the live
737
- conversation's last `role: "model"` entry (`Engine#live_conversation`, one lookup for both loops: a ChatLoop batch may
738
- end in a tool result), builds an `AnswerRefs` on that step alone with the question caps (50 / 32 KiB), fires with the
739
- step's text (`Engine#question_step_text`: the parser's thought and tool-call strip, then
740
- `OutputFormatter.strip_markup`), a frozen copy of the fields and a no-op `ask_user`, then sets `display_refs` on
741
- the step **in place** under the event lock (the loops and the forget layer hold entries by identity) and returns the
742
- fields with `refs:`. `QuestionDesk#request` forwards `refs` past its slice; `PendingQuestion#refs` carries them to
743
- `:question_requested`, the session file and the cards. `Web::App#with_question_refs` re-validates them (the pending
744
- question, `cards_for_display`) with the same caps; the page pairs them in the asking slot, on the card and on the
745
- step's text ([architecture.md](../architecture.md), the `display_refs` paragraph). The kit's
746
- `Testing::Bundle#question_asked` calls `QuestionAsked.fire` with a one-step conversation.
596
+ **Display-only fields.** `:after_turn` adds `event[:present]` (`AnswerDisplay`, the answer's `display` text) and
597
+ `event[:refs]` (`AnswerRefs`, refs kept as `display_refs`), built on the stored conversation's last message when it
598
+ is the model's. `AnswerDisplay::DISPLAY_ONLY` (`display`, `empty_answer`, `display_refs`) is dropped from every
599
+ model-facing copy (`strip` / `strip_all`: hooks' `messages:`, the recap, `ctx.messages`, after_generation), while
600
+ `KernelLoop#sanitize_history` and `ChatLoop#plain` copy them so the stored conversation keeps them across turns.
601
+ `Web::App#messages_for_display` re-validates `display_refs` (`AnswerRefs.for_display`).
602
+
603
+ **History stamps.** User prompts, merged input and steers, reminders, context and task notes and the model's replies
604
+ carry `at` (`MessageTime::KEY`, `message_time.rb`). A turn reads the wall clock once (`Turn#wall`): `:turn_started`'s
605
+ `at`, its prompt's and its reminders' stamps. The others are stamped by `Steer::Merge#messages` (a merge),
606
+ `ContextNote.message` (a note's `created_at`), `KernelLoop#iterate` / `#cancelled_result` and the chat loop's
607
+ `Run#with_thinking` / `Run#canceled` (each reply): **a new reply path stamps it too**. Tool results, `!cmd` messages and TurnNote notes carry none. **`at`
608
+ must survive the two rebuilds**: both model branches of `KernelLoop#sanitize_history` and `ChatLoop#plain` copy it
609
+ (an allow-list that drops a key loses it for good; `spec/message_time_history_spec.rb` checks both loops). It never
610
+ reaches the wire (prompt-caching.md) but is not in `DISPLAY_ONLY`: hooks and `ctx.messages` see it. Gap dividers
611
+ come from `MessageTime.gap_line`.
612
+
613
+ **`:question_asked`** (`QuestionAsked`, `question_asked.rb`) fires only from the two seams where a question of the
614
+ model's opens: `Engine#request_question` (the kernel's question handler; not non-interactive) and
615
+ `GuardrailWiring#request_approval` (after a stored approval and the non-interactive deny), never from
616
+ `QuestionDesk` (so not `ask_for_hook`, a relayed approval or the step-limit question). It is a no-op without a
617
+ subscriber. Else it finds the live conversation's last model entry (`Engine#live_conversation`), builds an
618
+ `AnswerRefs` on that step with the question caps, fires with `Engine#question_step_text`, and sets `display_refs` on
619
+ the step **in place** under the event lock (the loops and the forget layer hold entries by identity). The refs ride
620
+ `PendingQuestion#refs` to `:question_requested`, the session file and the cards; `Web::App#with_question_refs`
621
+ re-validates them ([architecture.md](../architecture.md), the `display_refs` paragraph).
747
622
 
748
623
  ### Hook lifecycle
749
624
 
750
- - **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`, from config.yml)
751
- live for the process: `clear_all` leaves them, so they apply to every turn.
752
- - **Turn-scoped hooks** (`Engine#register_hook`, `Registry#register`) are cleared by `clear_hooks` in
753
- `release_turn`.
754
- - Order of a fire: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
755
- - A hook that raises is logged; it never breaks the turn. A plugin's `chi.on` handler and a bundle's `hooks/*.rb`
756
- file also tell the user, once per label in this Engine (`Hooks.wrap(on_failure:)` → `HookRoutes#plugin_hook_failed`: a
757
- `:guardrail_warning` labelled `plugins`, with `Engine#plugin_warning` for a UI that joins later). The Engine hands
758
- that callback to the hook files through `ExtensionLoad.new(hook_failed:)` → `Hooks::BundleLoader.load(hook_failed:)`;
759
- a config hook (config.yml's own files) has none, and the test kit's sandbox gives none.
760
- - A notice from a hook goes to the running turn's sink and the observers. Outside a turn it is announced with
761
- `between_turns: true`; while the plugins load it is held and announced after. `EventRoutes#route` decides it, the
762
- same routes as a card's (`Engine#show_card`), with their own marks (`NOTICE_MARKS`, `CARD_MARKS`); a guardrails
763
- line after load takes only the turn / between-turns routes (`#route_by_turn`).
764
- - Plugin init tasks (`chi.init`, slow setup such as an MCP server's first start): a turn waits for the ones that
765
- provide tools before it builds its messages, and a Stop or Ctrl-C ends the wait.
625
+ - **Bundle hooks** (`Registry#register_bundle`) and **config hooks** (`register_persistent`) live for the process:
626
+ `clear_all` leaves them.
627
+ - **Turn-scoped hooks** (`Engine#register_hook`) are cleared by `clear_hooks` in `release_turn`.
628
+ - Fire order: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
629
+ - **A hook that raises is logged; it never breaks the turn.** A plugin's `chi.on` handler and a bundle's
630
+ `hooks/*.rb` also tell the user once per label (`Hooks.wrap(on_failure:)` → `HookRoutes#plugin_hook_failed`: a
631
+ `:guardrail_warning` labelled `plugins`); the callback reaches the hook files through
632
+ `ExtensionLoad.new(hook_failed:)`. A config hook has none, nor the test kit's sandbox.
633
+ - A notice goes to the running turn's sink and the observers; outside a turn it is announced with
634
+ `between_turns: true`; while the plugins load it is held. `EventRoutes#route` decides it, the same routes as a card's
635
+ (`Engine#show_card`); a guardrails line after load takes only the turn / between-turns routes (`#route_by_turn`).
636
+ - Plugin init tasks (`chi.init`): a turn waits for those that provide tools before it builds its messages; a Stop or
637
+ Ctrl-C ends the wait.
766
638
 
767
639
  ## Guardrails
768
640
 
769
- `ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
770
- the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
771
- known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
772
- `event[:blocked]` flag. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are
773
- disabled. Then the core checks run on the final call in `GuardrailWiring#checks` order
774
- (`lib/samagotchi/guardrail_wiring.rb`): the load failures first, then the participant's boundary
775
- (`participant_boundary`, `PairBoundary` for a pair turn else `ReviewBoundary`), then the hook asks, the protected
776
- paths, the scratch writes, the delegate child's boundary, the rules. That method is the order; it is not spelled
777
- out here so this page can't drift from it.
641
+ `ToolRunner` asks the kernel's `Guardrails::Gate` before each call with the call's `TurnContext`
642
+ (`Gate#evaluate(call, context:)`); **`GuardrailWiring` keeps no per-turn state**, it makes the guardrails context and
643
+ checks from the turn (`#context(turn)`, `#checks(turn)`). The gate is not itself a hook. It fires the
644
+ `:before_tool_call` hooks (the voters), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
645
+ `event[:blocked]`. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are disabled.
646
+ Then the core checks run on the final call in `GuardrailWiring#checks` order (`guardrail_wiring.rb`). **That method is
647
+ the order**; it is not spelled out here so this page can't drift from it.
778
648
 
779
- A gate that raises denies the call.
780
-
781
- The verdict is one of:
649
+ **A gate that raises denies the call.**
782
650
 
783
651
  - **allow**: the call runs.
784
- - **deny**: that call's output is replaced with denial text; the rest of the batch still runs.
785
- - **ask**: `Gate#settle_ask` first looks for a stored approval that covers the call. Otherwise
786
- `Engine#request_approval` asks the user through the question flow (attached TUI, web). A
787
- `--non-interactive` run has no one to ask, so the call is denied. An approval wider than this once is stored.
652
+ - **deny**: the output is replaced with denial text; the rest of the batch still runs.
653
+ - **ask**: `Gate#settle_ask` looks for a stored approval first, else `GuardrailWiring#request_approval` asks the user
654
+ through the question flow. A `--non-interactive` run has no one to ask: denied. An approval wider than this once is
655
+ stored.
788
656
 
789
- docs/guardrails.md covers the rules and the approval scopes.
657
+ docs/guardrails.md covers the rules, the boundaries and the approval scopes.
790
658
 
791
659
  ## Anytime commands
792
660
 
793
- A slash command a plugin registers with `anytime: true` (the btw bundle's `/btw`) runs on its own thread, even
794
- while a turn runs:
795
-
796
- ```
797
- Engine
798
- ├── spawn_anytime { ... } # a thread #shutdown waits for
799
- ├── running_anytime { ... } # cards and notices on this thread are announced at once,
800
- │ # marked anytime: true, never as the running turn's events
801
- ├── add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed:)
802
- └── await_init_tasks(controller, on_event)
803
- ```
804
-
805
- `add_init_task` and `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
661
+ A slash command a plugin registers with `anytime: true` (`/btw`) runs on its own thread, even while a turn runs:
662
+ `Engine#spawn_anytime` (a thread `#shutdown` waits for) and `#running_anytime` (cards and notices on that thread are
663
+ announced at once, marked `anytime: true`, **never as the running turn's events**), in `engine/cards.rb`.
664
+ `add_init_task` / `await_init_tasks` belong to plugin init tasks (`PluginTasks`), not to anytime commands.
806
665
 
807
666
  ## Shared loop behaviors
808
667
 
809
- Both loops:
810
-
811
- - <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`, `LLM::ChatLoop` only): a reply with no tool
812
- calls whose whole text is one tool-call block closed by the tag that opened it (`<function=` by `</function>`, `<parameter=` by `</parameter>` or
813
- `</function>`, `<tool_call>` by `</tool_call>`), with nothing else in it — the block need not carry inner tags — isn't an answer. A reply
814
- opening with prose or a fence, or saying anything after the close, stays an answer, and the check is one string test on a normal one. Nothing is
815
- guessed from the block and it never enters the history (`:generation_completed`
816
- and `:after_generation` have already seen the text): `:generation_retrying` with `restarted` voids the streamed text, `:empty_answer_retry` with
817
- `malformed` goes out, and a hidden `TurnNote.malformed_retry` asks again at the retry temperature. It has its own budget, so `retry.empty_answer: 0`
818
- doesn't turn it off, and a real tool call resets it. Two in a row fail the turn with `LLM::MalformedGeneration`, keeping its earlier steps; a Stop that lands on a stray reply ends the turn cancelled, not failed, because a stopped turn skips this path. Each is
819
- logged as `generation_malformed` with the shape, finish reason, served model and the text's tail. `KernelLoop` has its own path (`ToolCallParser#malformed`).
820
- - **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
821
- again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
822
- The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
823
- is kept. A length stop with the context at least 90% full is not retried. When the retries run out, the
824
- Engine keeps a `TurnNote.empty` note as the turn's end. A Stop that lands on an empty reply ends the turn cancelled, in both loops.
825
- - **A refusal** (chat loop only): a finish reason `refusal` (Messages), or `content_filter` (OpenAI) with no text, is
826
- an empty answer never retried; text streamed before a refusal is not kept, and tool calls with it are not run. A
827
- `content_filter` with text keeps it, marked `ChatLoop::FILTERED_MARK`, also never retried. `ModelResult#refusal` is `{category:}` (from the response's
828
- `stop_details`) or `{}`, and goes on the `TurnNote.empty` marker and `turn_summary.empty_answer` as `refusal`, so
829
- every UI words the notice `no answer: refused by the provider (cyber)`.
830
- - **A cut generation** (`after_cut`): a generation a plugin cut (`stop_generation`) spends the same retry budget
831
- with its own nudge; when it is used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the
832
- steer and spends nothing.
833
- - **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`). A
834
- worker's drain (`Worker#pending_input_drain`) takes every input file in name order except the ones
835
- `SessionInbox.waits_for_turn_end?` leaves for a turn of their own (`delivery: queue`, or images): those are
836
- skipped, not a barrier, so a line sent after them still merges. Only the files it claims reach
837
- `:input_merged`, the failure hand-back (`@merged_this_turn`) and the checkpoint; the skipped ones run after the
838
- turn, one turn each, in name order (`Worker#run_input_files`).
839
- - **Context status**: an estimate of the window's fill before each request,
840
- emitted on every request (`:context_status`) so the UI always has a current
841
- number, with a guidance line on the tail only when it rises far enough.
668
+ Both loops, unless marked:
669
+
670
+ - <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`, chat loop
671
+ only): a reply with no tool calls whose whole text is one tool-call block closed by the tag that opened it
672
+ (`<function=` by `</function>`, `<parameter=` by `</parameter>` or `</function>`, `<tool_call>` by
673
+ `</tool_call>`; inner tags not needed) isn't an answer. Prose or a fence before it, or anything after the close,
674
+ keeps it an answer. Nothing is guessed from the block and it never enters the history (`:generation_completed` and
675
+ `:after_generation` have already seen the text): `:generation_retrying`
676
+ with `restarted` voids the streamed text, `:empty_answer_retry` with `malformed` goes out, and a hidden
677
+ `TurnNote.malformed_retry` asks again at the retry temperature. Its own budget (`retry.empty_answer: 0` doesn't turn
678
+ it off; a real tool call resets it); two in a row fail the turn with `LLM::MalformedGeneration`, keeping its
679
+ earlier steps. A Stop on a stray reply ends the turn cancelled, not failed. Logged `generation_malformed`.
680
+ `KernelLoop` has its own path (`ToolCallParser#malformed`).
681
+ - **Empty-answer retry** (`EmptyAnswerRetry`): no visible text and no tool calls is asked again, up to
682
+ `retry.empty_answer` times (at most 3), with a hidden `TurnNote.empty_retry`, at temperature 0.6 only when the
683
+ configured sampling sets none. A length stop with the context ≥ 90% full is not retried. Out of retries, the turn
684
+ ends with a `TurnNote.empty` note. A Stop on an empty reply ends the turn cancelled.
685
+ - **A refusal** (chat loop only): finish reason `refusal`, or `content_filter` with no text, is an empty answer never
686
+ retried; text streamed before it is not kept and tool calls with it don't run. A `content_filter` with text keeps
687
+ it, marked `ChatLoop::FILTERED_MARK`, also never retried. `ModelResult#refusal` (`{category:}` or `{}`) goes on the `TurnNote.empty`
688
+ marker and `turn_summary.empty_answer`, so every UI words the notice the same.
689
+ - **A cut generation** (`after_cut`): a plugin's cut (`stop_generation`) spends the same retry budget with its own
690
+ nudge; used up, the turn ends cancelled (`:hook`). A steer's cut asks again with the steer and spends nothing.
691
+ - **Steering**: queued input goes in at iteration boundaries (`Steer.inject!`, `:pending_input_merged`). A worker's
692
+ drain takes every input file in name order except those `SessionInbox.waits_for_turn_end?` leaves for a turn of
693
+ their own (`delivery: queue`, images): skipped, **not a barrier**, so a line sent after them still merges. Only
694
+ the claimed files reach `:input_merged`, the failure hand-back and the checkpoint; the skipped ones run after the
695
+ turn, one turn each (`Worker#run_input_files`).
696
+ - **Context status**: an estimate before every request (`:context_status`), a guidance line on the tail only when
697
+ it rises far enough.
842
698
  - **Cancel**: text streamed before a cancel stays, marked `[interrupted]`.
843
- - **The same events**: generation events, tool dispatch and tool call events, and the before/after generation
844
- and tool call hooks.
845
-
846
- Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`: with thinking off, Qwen
847
- gets an empty thought after the cue, `QWEN_EMPTY_THOUGHT`, and Gemma 4 its empty thought channel,
848
- `GEMMA_EMPTY_THOUGHT`, with no `<|think|>` token before the system prompt; at any other level Qwen gets an open
849
- thought, `QWEN_OPEN_THOUGHT` (`<think>\n`), when the server's chat template writes one after its cue
850
- (`Thinking.opens_thought?` on the model's `/props`, a Qwen3.5+ template; `KernelLoop#native_prefill`, once per turn).
851
- The prefill is kept in the model message so the next prompt starts with what the server has cached; each
852
- generation's `ThoughtStreamSplitter` is seeded with it (an open thought's reasoning streams as `thinking:`), and the
853
- answer is stripped from prefill + response, so the reasoning never lands in it).
699
+ - **The same events** and generation / tool call hooks.
700
+
701
+ Native loop only: the Qwen open-block recovery, and the thinking prefill (`Thinking.native`,
702
+ `KernelLoop#native_prefill`, once per turn): with thinking off, Qwen gets `QWEN_EMPTY_THOUGHT` and Gemma 4
703
+ `GEMMA_EMPTY_THOUGHT` (no `<|think|>` token); otherwise Qwen gets `QWEN_OPEN_THOUGHT` when its chat template writes
704
+ one (`Thinking.opens_thought?`). The prefill is kept in the model message so the next prompt starts with what the
705
+ server cached; each generation's `ThoughtStreamSplitter` is seeded with it, so the reasoning never lands in the
706
+ answer.
854
707
 
855
708
  ## Key design principles
856
709
 
857
- 1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held.
858
- 2. **Turn-scoped and process-wide hooks.** `register_hook` hooks are cleared at `release_turn`; bundle hooks
859
- and config.yml hooks survive.
860
- 3. **Each loop works on its own copy of the conversation.** The Engine's `turn.messages` is what it sent. After
861
- a normal end or a Stop it keeps `result.conversation`; after a failure, the copy attached with
862
- `FailedTurn.attach` (a Ctrl-C's too); `turn.messages` only when it ended before the loop.
863
- 4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers are
864
- persistent.
865
- 5. **A stable system prompt for the cache.** `SystemPrompt#build` caches the prompt per Engine, keyed by
866
- `[chat, thinking, layers]` (chat/native, the thinking level, the turn's LLM context layers), and is reset
867
- only on a model switch, a tools change, a plugin's `prompt_changed!` (`Engine#prompt_changed!`), a profile
868
- change or a toolkit change (`Engine#apply_toolkit!`). The stable part (base prompt, the toolkit's not-loaded line,
869
- rg guidance, identity, model notes, preloaded memories, AGENTS.md, appended files, the toolkit's kit prompt, the
870
- plugins' prompt sections, memory indexes) comes before the volatile tail (model, working directory, session).
871
- prompt-caching.md covers this in detail.
710
+ 1. **Turn state is cross-thread safe.** TurnState uses one Monitor and calls nothing out while it is held; other
711
+ threads read the turn only through `Engine#turn_context` / `#last_turn_context`.
712
+ 2. **Turn-scoped and process-wide hooks.** `register_hook` hooks are cleared at `release_turn`; bundle and config
713
+ hooks survive.
714
+ 3. **Each loop works on its own copy of the conversation** (see "The model↔tool loop").
715
+ 4. **Events are the contract.** UIs read events, not internal state. `on_event` is per turn; observers persist.
716
+ 5. **A stable system prompt for the cache.** `SystemPrompt#build` caches per Engine, keyed by
717
+ `[chat, thinking, layers]`, reset only on a model switch, a tools change, `Engine#prompt_changed!`, a profile
718
+ change or `Engine#apply_toolkit!`. The section order and the stable/volatile split are prompt-caching.md's.
872
719
 
873
720
  ## Key files
874
721
 
875
- Paths are under `lib/samagotchi/`.
722
+ Paths are under `lib/samagotchi/`. The session manager, the Worker, the Bridge, task watches and guardrails files
723
+ are in [architecture.md](../architecture.md)'s file table.
876
724
 
877
725
  | File | Role |
878
726
  |---|---|
879
- | `engine.rb` | Engine: the wiring (`initialize`, the `build_*` methods, the kernel), the turn skeleton (`run_turn`, `begin_turn`, `prepare_turn`'s ordered steps, `generate`, `end_turn`) and `shutdown`; the rest is in its parts, below |
880
- | `engine/steering.rb` | `Engine::SteeringPart`: steering the running turn (or the next), the drains, the cut, the activity clock, the cancel |
881
- | `engine/side_requests.rb` | `Engine::SideRequestsPart`: ctx.ask_model's and Check this's side requests, their side usage, System 1's recording |
882
- | `engine/recap.rb` | `Engine::RecapPart`: the idle recap job and `/recap`, the idle scheduler's start and stop |
883
- | `engine/models.rb` | `Engine::ModelsPart`: the model's names, identity and notes, `/model`'s switch, the profile, the loop it runs on |
884
- | `engine/model_settings.rb` | `Engine::ModelSettingsPart`: the session's model settings (SessionModelSettings) and the snapshots (EngineSnapshots), forwarded |
885
- | `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements, load events, the emit helpers |
886
- | `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands and their threads |
887
- | `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives' view, proposals and audit |
888
- | `engine/messages.rb` | `Engine::MessagesPart`: the session messages API (out-of-turn edits, checkpoints, the live conversation) |
889
- | `engine/plugins.rb` | `Engine::PluginsPart`: loading plugins, init tasks and staged tools, `Plugin::Host` |
890
- | `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime (HookRoutes, forwarded), reminders |
891
- | `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers and the first turn's load warnings |
727
+ | `engine.rb` | The wiring (`initialize`, `build_*`, the kernel), the turn skeleton (`run_turn`, `begin_turn`, `prepare_turn`, `turn_messages`, `generate`, `end_turn`), `shutdown` |
728
+ | `engine/steering.rb` | `Engine::SteeringPart`: steers, the drains, the cut, the activity clock, the cancel |
729
+ | `engine/side_requests.rb` | `Engine::SideRequestsPart`: ctx.ask_model's and Check this's side requests, side usage, System 1's recording (`system_one_recorder`) |
730
+ | `engine/recap.rb` | `Engine::RecapPart`: the idle recap and `/recap` |
731
+ | `engine/models.rb` | `Engine::ModelsPart`: model names, identity, notes, `switch_model!`, the profile, `backend` |
732
+ | `engine/model_settings.rb` | `Engine::ModelSettingsPart`: SessionModelSettings and EngineSnapshots, forwarded |
733
+ | `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements |
734
+ | `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands |
735
+ | `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives stage's builder and forwards (`announce_objectives`, `show_objective_proposals`) |
736
+ | `engine/messages.rb` | `Engine::MessagesPart`: out-of-turn message edits, checkpoints, the live conversation |
737
+ | `engine/plugins.rb` | `Engine::PluginsPart`: plugins, init tasks, staged tools, `Plugin::Host` |
738
+ | `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime, reminders |
739
+ | `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers, the first turn's load warnings |
892
740
  | `engine/participants.rb` | `Engine::ParticipantsPart`: the turn's author, a pair turn's stop, the participants line |
893
- | `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt and its memory names |
894
- | `engine/verify.rb` | `Engine::VerifyPart`: Verify and Revise anchors at a turn's end, the verdict |
741
+ | `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt |
742
+ | `engine/verify.rb` | `Engine::VerifyPart`: the Verify/Revise anchors' stage builder |
895
743
  | `engine/questions.rb` | `Engine::QuestionsPart`: ask-user-question (QuestionDesk, forwarded) |
896
- | `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, System 1's toolkit suggestion |
897
- | `engine/context_judge.rb` | `Engine::ContextJudgePart`: the attached-context candidate judge and its cards |
898
- | `engine/turn_helpers.rb` | `Engine::TurnHelpersPart`: a turn's settings, images, model ref, used memories, the turn-end warm-up |
899
- | `engine/turn_end.rb` | `Engine::TurnEndPart`: the normal ending and after-turn hooks, release, what an ending keeps, last_turn, the answer's display |
900
- | `event_fanout.rb` | `EventFanout`: how an event reaches the turn's sink and the observers (`emit_event`), the loops' stream handler, the working set's emit and dedupe; the Engine forwards |
901
- | `engine_snapshots.rb` | `EngineSnapshots`: the UIs' read model, `session_state_snapshot` (cheap, never raises) and `stats_snapshot` (may probe `/props`); the Engine forwards |
902
- | `model_binding.rb` | `ModelBinding`: the model choice and the prompt profile resolved for it, under one mutex, and the overlay keys; the Engine forwards |
903
- | `turn_endings.rb` | `TurnEndings`: what each turn ending keeps and announces (the kept messages and their `TurnNote`, `failed_event`, `turn_summary`, `stop_facts`); the Engine forwards |
904
- | `turn_state.rb` | Cross-thread turn state (running, controller, sink, steers, activity clock) |
905
- | `steer_cut.rb` | `SteerCut`: the Engine's seam for a message cutting the generation |
906
- | `generation_phase.rb`, `waiting_steer.rb` | Whether a steer may cut the running generation, and the steer that waits to |
907
- | `effective_model.rb` | `EffectiveModel` (the effective model's settings, a snapshot per read; `.of_session`), `ModelChoice`, the fallbacks' log lines (`Unresolved`) |
908
- | `session_setting.rb` | `SessionSetting`: the per-session settings' descriptors (own value, chips, the web's fallback) |
909
- | `session_model_settings.rb` | `SessionModelSettings`: the session's settings on its effective model (its own thinking, reasoning_history and llm_context over the model's, the `/model` and `/stats` summaries, what a turn resolves, `#model_setting`'s fallbacks); the Engine forwards its readers there |
910
- | `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb` | `/thinking` and `/reasoning-history` (`LevelSettingCommand` and its two Specs) |
911
- | `thinking_notices.rb` | `ThinkingNotices`: the notices a turn's thinking level leads to |
912
- | `steer_cut_after.rb`, `cut_policy.rb` | The model's cut wait (`SteerCutAfter.resolve`); the loops' cut path (`CutPolicy`) |
913
- | `steer.rb` | Queued input and steers at iteration boundaries |
914
- | `kernel_loop.rb` | Native loop: `#run` (format, generate, parse, dispatch, iterate) |
915
- | `llm/chat_loop.rb` | Chat loop: `#complete` (tool schemas per request, wire conversion, block replay) |
916
- | `llm/adapters.rb` | `LLM::Adapters::BY_API`: which chat adapter a host's api gets |
917
- | `llm/openai_chat.rb` | `LLM::OpenAIChat`, the `/v1/chat/completions` adapter |
918
- | `llm/adapter_common.rb` | `LLM::AdapterCommon`: what both chat adapters share (the HTTP layer and its timeouts, the paged model list, the SSE line loop, JSON errors) |
919
- | `llm/refusal_memo.rb` | `LLM::RefusalMemo`: what a host refused (for one model, or the whole host), remembered for its holder's life (an adapter, or the worker's ChatLoop) |
920
- | `llm/anthropic_messages.rb`, `llm/anthropic_wire.rb`, `llm/anthropic_assembly.rb`, `llm/dialect.rb` | The `/v1/messages` adapter, its wire translation, its response, its dialects ([anthropic.md](anthropic.md)) |
921
- | `llm/prompt_cache.rb` | Cache breakpoints on the OpenAI path, and the `cache_control` both adapters send |
922
- | `llm/turn_settings.rb` | `LLM::TurnSettings`, what a turn runs under (vision, sampling, thinking, reasoning history, …) |
923
- | `llm/native_tool_normalizer.rb`, `llm/stream_drops.rb` | A chat adapter's tool calls as chi's call hashes (`Tools::BuiltinCalls`); asking again for a step whose stream dropped (both loops) |
924
- | `reasoning_history.rb` | `ReasoningHistory`: the levels, `.resolve`, `.run_start` |
925
- | `llm/backend.rb` | The `LLM::ModelBackend` contract (`#complete(...)`) |
926
- | `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
927
- | `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
928
- | `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
929
- | `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` (a `memory_read`'s: `memory.read_max_chars`) is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-output-limits.md](tool-output-limits.md)) |
930
- | `tool_row_fields.rb`, `row_refs.rb` | `ToolRowFields`: a tool row's fields beyond its params (`ACTIVITY_KEYS`, `EVENT_KEYS`), the same live and on a reload; `RowRefs`: a plugin tool's row refs (`refs:`), checked, capped and saved with the result |
931
- | `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
932
- | `empty_answer_retry.rb` | The empty-answer and cut retry budget |
933
- | `prompt_warmup.rb` | The turn-end warm-up |
934
- | `tools/registry.rb` | The tools a session offers: schemas, handlers, order |
935
- | `tools/tool_index.rb` | `Tools::ToolIndex`: `find_tools` and `call_tool`, the tools a session knows but doesn't declare one by one (the sources' groups and the built-ins its toolkit leaves out), `#declare`, `#refresh!`, `#unwrap` |
936
- | `plugin/tool_spec.rb` | `Plugin::ToolSpec`: a checked tool declaration (`chi.tool`, a tool set, `ToolSpec.build`), what `Registry#register` gets; `#per_call` |
937
- | `system_prompt.rb` | System prompt construction (stable part, volatile tail) |
938
- | `prompt_files.rb`, `model_notes.rb` | The AGENTS.md chain and appended files; the model notes the prompt loads |
939
- | `toolkit.rb`, `toolkit_command.rb` | A session's toolkit (`Toolkit#resolve`, what `Registry` declares); `/toolkit` |
940
- | `toolkit_binding.rb` | `ToolkitBinding`: the Engine's toolkit seam (the kits, `#explained`, `ctx.use_toolkit`, the turn-start `#apply!` behind `Engine#apply_toolkit!`) |
941
- | `hooks/registry.rb` | Hook registry, fire order, `Hooks::Runtime` |
942
- | `hook_routes.rb` | `HookRoutes`: the Engine's routes for a hook's or plugin's notice (`:hook_notice`, `fallback_for:`), question, stop and steer, and the guardrails' after-load line |
943
- | `question_asked.rb` | `QuestionAsked`: the `:question_asked` fire, the asking step's refs (`display_refs` in place, the pending question's `refs`) |
944
- | `question_desk.rb`, `pending_question.rb`, `question_answer.rb` | `QuestionDesk`: the one open question (ask, post, answer, cancel, relay mark); `PendingQuestion` and `QuestionAnswer`, its values (Hashes only in events, Bridge JSON, the session file and the hook/plugin `ask_user` result) |
945
- | `hook_question.rb` | `HookQuestion`: a hook's or plugin's `ask_user` question as one value, from the runtime/host edge to `QuestionDesk#ask_for_hook` |
946
- | `event_routes.rb` | `EventRoutes`: where a card (`Engine#show_card`), a notice (`HookRoutes#notify`) and a guardrails line (`#guardrail_notify`, turn or between turns only) go: held while the plugins load, an anytime command's, an init task's, a running turn's event, else announced or held for `holding_announcements` |
947
- | `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView` (what the kernel's peer tools see of the Engine) and `Engine::RelayPeer` (a delegate's approval relay); `PeerSources`: the attached context sources a peer bundle declares as its peer's answers (`chi.peer_source`) |
948
- | `extension_load.rb` | What an Engine loads at start: config.yml and bundle hooks, plugins, `bundles:` settings; events held while plugins load |
949
- | `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context and approvals; the verdict per call |
950
- | `events.rb` | Event type sets shared by the Worker, the Bridge, the TUI and the web |
951
- | `cancellation_controller.rb` | A turn's cancel signal, and a generation's child controller |
952
- | `session.rb` | Session model (status, messages, persistence) |
953
- | `turn_checkpoint.rb` | `TurnCheckpoint`: a worker's running turn saved at the loop's iteration boundaries (`Session#turn_checkpoint`), so a killed worker loses at most `INTERVAL` of it |
954
- | `context_status.rb` | `ContextStatus`: one turn's context tracking (the status line's value, the `:context_status` event, the model's guidance line) ([context-telemetry.md](context-telemetry.md)) |
955
- | `session_manager.rb` | Background session processes (workers) and their IPC |
956
- | `worker_wakes.rb` | `WorkerWakes`: a worker's wake budget for the turns nobody typed (`session.max_wakes`) |
957
- | `task_watch.rb`, `task_finish_note.rb` | `TaskWatch`: the background tasks a worker's session started that the model hasn't seen finish; `TaskFinishNote`: the note one leaves when it does ([background-tasks.md](background-tasks.md#finish-wake)) |
958
- | `side_usage.rb` | `SideUsage`: one side request's usage (a recap, `ctx.ask_model`), folded into the session's side sums (`:side_usage`) |
959
- | `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([bridge.md](bridge.md)) |
960
- | `guardrails/parent_policy.rb` | `Guardrails::ParentPolicy`: which options of a question a parent agent may pick (`guardrails.parent_answers`) |
744
+ | `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, the toolkit suggestion's builder |
745
+ | `engine/context_judge.rb` | `Engine::ContextJudgePart`: the candidate judge's builder and `judge_late_candidates` |
746
+ | `engine/turn_helpers.rb` | `Engine::TurnHelpersPart`: turn settings, images, model ref, used memories, the warm-up's builder |
747
+ | `engine/turn_end.rb` | `Engine::TurnEndPart`: `complete_turn`, release, what an ending keeps, `last_turn`, the answer's display |
748
+ | `turn_stages/*.rb`, `system_one/recorder.rb` | The turn stages and their recorder ("Turn stages") |
749
+ | `turn_context.rb`, `turn_state.rb`, `turn_endings.rb`, `turn_origin.rb`, `turn_checkpoint.rb` | `TurnContext`; cross-thread turn state; the endings' builders; `TurnOrigin`; the running turn's checkpoint |
750
+ | `event_fanout.rb`, `events.rb`, `event_routes.rb` | `EventFanout` (sink + observers, the stream handler, the working set's emit); the shared event sets; where a card, notice or guardrails line goes |
751
+ | `engine_snapshots.rb` | `session_state_snapshot` (cheap, never raises), `stats_snapshot` (may probe `/props`) |
752
+ | `model_binding.rb`, `effective_model.rb`, `session_model_settings.rb`, `session_setting.rb` | The model choice and profile; `EffectiveModel` / `ModelChoice`; the session's settings on its model; the per-session setting descriptors |
753
+ | `thinking_notices.rb`, `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb`, `reasoning_history.rb` | Thinking notices; `/thinking`, `/reasoning-history`; `ReasoningHistory` (`.resolve`, `.run_start`) |
754
+ | `steer.rb`, `steer_cut.rb`, `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb`, `cut_policy.rb` | Steers at boundaries; the cut seam and its parts; the cut wait; the loops' cut path |
755
+ | `kernel_loop.rb` | The native loop |
756
+ | `reprefill_meter.rb` | `ReprefillMeter`: the Engine's one re-prefill baseline, given to both loops (prompt-caching.md, "Reading the numbers") |
757
+ | `llm/chat_loop.rb`, `llm/adapters.rb`, `llm/openai_chat.rb`, `llm/adapter_common.rb`, `llm/refusal_memo.rb` | The chat loop; `BY_API`; the OpenAI adapter; what both adapters share; refusals remembered |
758
+ | `llm/anthropic_messages.rb`, `llm/anthropic_wire.rb`, `llm/anthropic_assembly.rb`, `llm/dialect.rb`, `llm/prompt_cache.rb` | The Messages adapter ([anthropic.md](anthropic.md)); cache breakpoints |
759
+ | `llm/backend.rb`, `llm/native_backend.rb`, `llm/model_result.rb`, `llm/turn_settings.rb` | `ModelBackend`; `NativeBackend`; `ModelResult`; `TurnSettings` |
760
+ | `llm/native_tool_normalizer.rb`, `llm/stream_drops.rb`, `llm/stray_tool_call.rb` | Adapter tool calls as chi's; a dropped stream asked again; a reply that is only a tool call |
761
+ | `tool_runner.rb`, `tool_response.rb`, `tool_row_fields.rb`, `row_refs.rb` | Per call; per batch; row fields; plugin row refs |
762
+ | `tool_output_cut.rb`, `tool_output_files.rb` | The output cap's policies; an execute's saved whole output ([tool-output-limits.md](tool-output-limits.md)) |
763
+ | `tools/tool_surface.rb`, `tools/registry.rb`, `tools/tool_index.rb`, `plugin/tool_spec.rb` | The turn's surface; the Registry; `find_tools` / `call_tool`; a checked plugin tool declaration |
764
+ | `toolkit.rb`, `toolkit_command.rb`, `toolkit_binding.rb` | Toolkits; `/toolkit`; the Engine's toolkit seam (`#published`, `#apply!`) |
765
+ | `empty_answer_retry.rb`, `prompt_warmup.rb` | The empty-answer and cut retry budget; the turn-end warm-up |
766
+ | `system_prompt.rb`, `prompt_files.rb`, `model_notes.rb` | The system prompt; AGENTS.md and appended files; model notes |
767
+ | `hooks/registry.rb`, `hook_routes.rb`, `hook_question.rb`, `extension_load.rb` | Hook registry and `Hooks::Runtime`; the hook routes; `HookQuestion`; what an Engine loads at start |
768
+ | `question_desk.rb`, `pending_question.rb`, `question_answer.rb`, `question_asked.rb` | The one open question and its values; `:question_asked` |
769
+ | `engine_peers.rb`, `peer_sources.rb` | `Engine::PeerView`, `Engine::RelayPeer`; `PeerSources` |
770
+ | `guardrail_wiring.rb`, `guardrails/gate.rb` | The gate's context, checks and approvals; the verdict per call |
771
+ | `cancellation_controller.rb`, `context_status.rb`, `side_usage.rb`, `degrade.rb` | A turn's cancel signal; context tracking ([context-telemetry.md](context-telemetry.md)); a side request's usage; `Degrade` |