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