samagotchi 0.154.0 → 0.155.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -6,12 +6,17 @@ and the attached TUI, a `--non-interactive` one-shot) runs turns the same way wi
6
6
  they render.
7
7
 
8
8
  ```
9
- Engine#run_turn → backend.complete(...) → KernelLoop#run (native) or ChatLoop#complete (chat)
10
- → per iteration: one generation, then ToolResponse.run_batch over the calls
11
- → ToolRunner#run per call → Guardrails::Gate → KernelLoop#dispatch_tool_call
9
+ Engine#run_turn → backend.complete(..., context:) → KernelLoop#run (native) or ChatLoop#complete (chat)
10
+ → per iteration: one generation, then ToolResponse.run_batch(context:) over the calls
11
+ → ToolRunner#run(call, context:) per call → Guardrails::Gate#evaluate(call, context:)
12
+ → KernelLoop#dispatch_tool_call(call, context) → the handler's ToolContext(kernel, context)
12
13
  → repeat until the model answers without tool calls, or the iteration limit
13
14
  ```
14
15
 
16
+ `context` is the turn's `TurnContext` (`lib/samagotchi/turn_context.rb`, see "Turn and TurnContext" 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.
19
+
15
20
  ## Quick reference: the turn lifecycle
16
21
 
17
22
  ```
@@ -19,61 +24,63 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
19
24
  pending_input:, continue:, origin:, images:, id:, resumed:, resume_from:, input_waiting:)
20
25
  │
21
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)
22
29
  │ # (then, on a continue, turn.resumed = resumed (a step-limit Continue) and
23
30
  │ # turn.resume_from = resume_from (its run's start; nil: it starts cold))
24
31
  │ # @session = session, session.status = RUNNING, used memories absorbed,
25
- │ # TurnState#begin!(controller:, sink:, input_waiting:) (a new CancellationController when none
26
- │ # is given; input_waiting: the caller's peek, Engine#input_waiting?, which task_wait,
27
- │ # delegate_result's wait and execute's early hand-over poll),
28
- │ # a recap in flight invalidated, GuardrailWiring#begin_turn(origin)
29
- │ # Returns a Turn {session, prompt (nil on a continue), continue, on_event, controller,
30
- │ # origin, started_at, id (the caller's id:, else a new uuid), resumed, resume_from,
31
- │ # wall (MessageTime.now)}
32
+ │ # 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
32
42
  │
33
43
  ├── Client.swap_probe_cancel(turn.controller) # a Stop cuts this turn's /props probes
34
44
  │
35
- ├── prepare_turn(turn, images)
36
- │ # Emits :turn_started {session_id, prompt, turn_id, at (the turn's wall time, Turn#wall: read once,
37
- │ # the user message's and the reminders' stamp too), continue: true (only on a continue),
38
- │ # resumed: true (only on a step-limit Continue: resumed: given),
39
- │ # images (only when there are any), cut_after (the model's cut wait, s)}
40
- │ # start_toolkit_suggestion: ToolkitSuggest's ask on its own thread from a copied snapshot (audit and on
41
- │ # mode: one eligibility; SideJudgments' :toolkit kind: one in flight, warn-not-raise). Audit never blocks the turn or changes the session; on mode
42
- │ # returns a ToolkitSuggest::Pending the turn joins below. See configuration.md (Toolkits) and
43
- │ # guardrails.md (System 1).
44
- │ # refresh_profile! (may ask the server's /props once)
45
- │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name, loads_tools … are set in
46
- │ # generate, after the hooks, from the final model),
47
- │ # from one EffectiveModel; ThinkingNotices#turn_started keeps the level and says one the host can't do
48
- │ # announces guardrail load failures, starts and awaits plugin init tasks, applies staged tools,
49
- │ # then (on mode) settle_toolkit_suggestion: waits for the ask up to System 1's timeout from its
50
- │ # start, or a Stop (which wins over an answer already in, and cuts the ask's request through
51
- │ # Pending#cancel_controller; a Stop after a timeout doesn't), and on a candidate picked ≥ threshold
52
- │ # ToolkitBinding#suggest sets the session's toolkit, origin suggested, saved, with the notice;
53
- │ # then start_context_judge (owner turns only: ContextCandidatesLog#look, and unless
54
- │ # context.candidates.mode is off, ContextJudge's asks on SideJudgments' :context kind, each answer to
55
- │ # ContextCandidateChoice#after_judged (on mode: attach to the session's folder, or a card held while a
56
- │ # turn runs and shown from run_turn's ensure, show_context_cards_after_turn); on a shared
57
- │ # local System 1 host they wait for the turn's end: started before warm_up_next_turn, whose warm-up
58
- │ # waits for them, or from run_turn's ensure; docs/context.md, The judge);
59
- │ # then the session's toolkit (apply_toolkit!: Registry#toolkit, ToolIndex#refresh! for find_tools'
60
- │ # chi line, a prompt rebuild when it changed; a warn notice per plugin tool that arrived mid-session
61
- │ # with nothing to reach it, Registry#stranded_names). A Stop during the probes or the waits returns false
62
- │ # (the ask's line says canceled; an ensure marks one the turn never settled failed).
63
- │ # fires the :session_start hook on the Engine's first turn, then the :before_turn hook
64
- │ # (a frozen copy of the history, the prompt, and resumed)
65
- │ # turn_messages: system head + history + due reminders (:reminder_injected) + the user message
66
- │ # + the plugins' staged tool loads (ctx.load_tools: a kind "tool_change" system entry per bundle,
67
- │ # only when the turn's host takes tool changes; #append_tool_loads)
68
- │ # Returns false when a Stop came during the probes, the init wait or the toolkit ask, else true
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
69
73
  │
70
74
  ├── generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
71
75
  │ # one EffectiveModel (after the hooks, which may switch the model): sync_kernel_client!, then
72
- │ # @kernel.turn_settings = turn.settings with model_name, window_setting, llm_context (the
73
- │ # session's, the model's/host's, else llm_context.strategy) and price
74
- │ # (hosts.<name>.models.<id>.price), all read per turn
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
75
81
  │ # turn.limit = max_iterations or IterationLimit.for
76
- │ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input))
82
+ │ # backend(model).complete(messages: turn.messages, ..., pending_input: turn_drain(pending_input),
83
+ │ # context: turn.context)
77
84
  │ # Returns LLM::ModelResult (text, conversation, canceled?, exhausted?, tool_activity,
78
85
  │ # context_status, empty_steps, empty_retries)
79
86
  │
@@ -85,9 +92,12 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
85
92
  │ # a continue turn's origin carries its offer turn's, ContinueOffer#run_continue_turn),
86
93
  │ # and :turn_completed {result, turn_summary, display_pending} or :turn_canceled
87
94
  │ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
88
- │ # stamp_opening puts a Verify turn's `verify` and a completed Revise turn's `revises`
89
- │ # (re-targeted to its stack's top) on the opening message, metadata never sent, and
90
- │ # every end event carries `turn_id`, :turn_completed `verify` / `verdict` / `revises` when there are
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)
91
101
  │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
92
102
  │ # refs: the AnswerRefs adder)
93
103
  │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
@@ -95,18 +105,41 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
95
105
  │ # attached refs, or display: nil when the web was told to wait for it)
96
106
  │ # then the :session_end hook
97
107
  │
98
- ├── warm_up_next_turn(turn, result)
108
+ ├── @turn_warmup.after(turn, result)
99
109
  │ # local llama.cpp on the native loop only: prefills the next turn's prompt while the user
100
110
  │ # reads (PromptWarmup; see prompt-caching.md, "The turn-end warm-up"). Never fails the turn.
101
111
  │
102
112
  └── ensure: release_turn(probe_cancel_before)
103
- # TurnState#finish! (turn flag off, controller, sink and steers cleared)
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)
104
115
  # SteerCut#finished! (its GenerationPhase finished, its WaitingSteer cleared)
105
116
  # probe cancel swapped back
106
117
  # the context window cache dropped, so the next turn asks the server again
107
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
108
121
  ```
109
122
 
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`).
130
+
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 |
138
+
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.
142
+
110
143
  ### Error paths
111
144
 
112
145
  ```
@@ -176,7 +209,7 @@ Stopped before the model was asked (prepare_turn returned false):
176
209
  **Degrading.** Work that has a fallback when it fails (a prompt section left out, an empty list from the Bridge) runs
177
210
  in `Degrade.call(area, event, fallback:, rescue:) { … }` (`degrade.rb`): same rescue class and fallback as a bare rescue,
178
211
  plus one WARN in the debug log per process for each `[area, event]` (DEBUG after; nothing on stderr). A bare `rescue`
179
- whose body is only a literal (`nil`, `false`, `[]`, `{}`, `""`, `0`) is flagged in lib by the `Samagotchi/SilentRescue`
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`
180
213
  cop; a silence that is right (a client gone, a routine probe, Log's own internals) carries `# degrade-ok: why`.
181
214
 
182
215
  ## The model↔tool loop
@@ -252,9 +285,10 @@ ChatLoop#complete(messages:, max_iterations:, on_stream_event:, cancel_controlle
252
285
 
253
286
  The differences from the native loop:
254
287
 
255
- - The tools go in each request's `tools` field (`tool_definitions(conversation, model:)`), not in the system prompt.
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.
256
290
  On an adapter that loads tools (`#loads_tools?`: `AnthropicMessages`), the tools the session reaches but doesn't
257
- declare follow, `defer_loading: true` (`Tools::DeferredTools`; anthropic.md, "Deferred tools"); the Run keeps the
291
+ declare follow, `defer_loading: true` (`ToolSurface#deferred_for`; anthropic.md, "Deferred tools"); the Run keeps the
258
292
  last request's deferred names and logs a call of one (`tool_direct_deferred`).
259
293
  - The conversation is converted to the OpenAI wire format (`wire_messages`), for every chat host;
260
294
  `AnthropicMessages` translates that again into Messages' `system` and content blocks (`LLM::AnthropicWire`).
@@ -298,25 +332,26 @@ Both loops run a batch of calls the same way:
298
332
  ```
299
333
  One iteration with tool calls:
300
334
  KernelLoop#dispatch_calls / ChatLoop::Run#dispatch
301
- → ToolResponse.run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
302
- # chat passes a block, called with each run as it finishes
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
303
338
  → :tool_dispatch_started {iteration, call_count}
304
- → for each call: ToolRunner#run(call, iteration:, call_index:, call_count:,
339
+ → for each call: ToolRunner#run(call, context:, iteration:, call_index:, call_count:,
305
340
  on_stream_event:, max_tool_output_chars:)
306
341
  → under TurnSettings#loads_tools, a direct call of a source tool's acts_as (mcp_fs_read) is wrapped
307
342
  into the call_tool call it stands for (Registry::Index#wrap, ToolIndex#wrap)
308
343
  → first, the call's entry unwraps it (Registry::Entry#unwrap: a call_tool of chi/<name>, a built-in or
309
344
  a plugin tool, becomes that tool's call with via: "call_tool", ToolIndex#unwrap); the rest sees the
310
345
  real call
311
- → a tool the session's toolkit leaves out (Registry#left_out; with via:, one Registry#indexed doesn't
312
- reach) skips the gate: no hooks, no card; KernelLoop#dispatch answers it (the call_tool route for a
313
- reachable tool, the not-loaded error for a removed one). Under TurnSettings#loads_tools a reachable
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
314
349
  built-in or plugin tool called by its own name is no such call: it runs through the gate, and KernelLoop#dispatch
315
- takes it (#loaded_entry)
316
- → Guardrails::Gate#evaluate (the before_tool_call hooks, then the core checks)
350
+ takes it (ToolSurface#entry_for)
351
+ → Guardrails::Gate#evaluate(call, context:) (the before_tool_call hooks, then the core checks)
317
352
  → :tool_call_started
318
- → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
319
- → KernelLoop#dispatch_tool_call, or the denial text
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
320
355
  → :tool_call_completed, then the :after_tool_call hook
321
356
  → returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
322
357
  shown_label, refs, diff and output_file; fields a call doesn't produce are nil
@@ -328,7 +363,9 @@ One iteration with tool calls:
328
363
  (one per call of the batch for native, one for chat); LLM context edits name outputs by them
329
364
  ```
330
365
 
331
- **ToolRunner** (`ToolRunner#run`):
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).
332
369
  - Unwraps a call_tool of a built-in first (`Registry::Entry#unwrap`), so `original`, the params and the label
333
370
  are the built-in's and no "ran as:" line follows.
334
371
  - Runs the gate first, so `:tool_call_started` shows the call that will run (a before_tool_call hook may
@@ -344,15 +381,16 @@ One iteration with tool calls:
344
381
  `refs` is a plugin tool's row refs: its registry entry's `refs` (chi.tool's `refs:` lambda, `ToolSpec#per_call`), run
345
382
  once per call by `RowRefs.for` (never raises: a raise or a non-list gives none, `tool_refs_failed`; each entry a
346
383
  `Ref`, a bad one dropped, `tool_refs_rejected`; a relative file resolved against the session's folder
347
- (`KernelLoop#session_cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
384
+ (`TurnContext#cwd`, else the process's), one outside it (its spelling or its real path) dropped, a kept one
348
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
349
386
  as `refs:`, saved with the result as `tool_refs` (`ToolResponse::SAVED_KEYS`, never sent), and
350
387
  `MessageParts#tool_part` reads it back through `RowRefs.for_display` (checked again). The page binds them to the
351
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
352
389
  menu.
353
390
  - Settles an ask verdict after `:tool_call_started`, so a UI shows the tool line and then the approval.
354
- - Dispatches through `KernelLoop#dispatch_tool_call`, or writes the denial text. Unknown tools are answered in
355
- `KernelLoop#dispatch` ("Error: no such tool ...", never repeating the wrong name).
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).
356
394
  - For edit and write: diffs the file before and after, and refreshes a memory's index line
357
395
  (`MemoryBundle::IndexSync`).
358
396
  - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
@@ -360,7 +398,7 @@ One iteration with tool calls:
360
398
  policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
361
399
  kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
362
400
  and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
363
- (`ToolOutputFiles`, through `KernelLoop#session_dir`, which the Engine sets). See
401
+ (`ToolOutputFiles`, in the turn's `TurnContext#session_dir`). See
364
402
  [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
365
403
  event and the after_tool_call hook get that text.
366
404
  - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
@@ -384,6 +422,31 @@ One iteration with tool calls:
384
422
  A denied call gets denial text in place of its output; the rest of the batch still runs. After a hook's
385
423
  `stop_turn`, the gate denies every remaining call of the batch.
386
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`.
449
+
387
450
  ## State management
388
451
 
389
452
  ### TurnState (cross-thread)
@@ -394,6 +457,8 @@ read: the idle scheduler, the Bridge's request threads, plugin threads, the UIs.
394
457
  ```
395
458
  TurnState
396
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)
397
462
  ├── controller (the running turn's CancellationController)
398
463
  ├── thread, ended (the turn's own thread; its end event is out: post-turn work)
399
464
  ├── sink (the turn's on_event)
@@ -402,20 +467,38 @@ TurnState
402
467
  └── activity clock (last_activity_at, activity_seq)
403
468
  ```
404
469
 
405
- - `begin!(controller:, sink:)`: running, with controller and sink (and the calling thread, the turn's own), in one
406
- step; carried steers become steers.
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.
407
476
  - `ended!`: the turn's end is decided (`end_turn`, next to `turn.ended`): its after_turn / session_end hooks run
408
477
  after it and before `finish!`.
409
478
  - `own_controller(handler:)`: the controller for work done for the turn itself, which a plugin's `ctx.http` call
410
479
  without `cancel:` stops with (`Plugin::Host#cancel_controller`, `Engine#plugin_cancel_controller`): the turn's, on
411
480
  its thread or inside a plugin handler, until `ended!`; nil elsewhere (an anytime command, a service, a plugin's
412
481
  own thread) and outside a turn. An init task's own controller wins inside one.
413
- - `finish!`: not running, controller, thread and sink cleared; returns the steers never taken (logged as dropped).
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).
414
484
  - `steer(text, source:)`: queues a steer for the running turn (false with no turn).
415
485
  - `steer_next_turn(text, source:)`: queues a steer for the next turn that begins.
416
486
 
417
487
  The lock is a leaf: nothing is called out while it is held.
418
488
 
489
+ **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!`.
501
+
419
502
  ### EffectiveModel (a snapshot per read)
420
503
 
421
504
  `EffectiveModel.of(registry, choice)` resolves the model once (`HostRegistry#resolve`: host, client, the id
@@ -464,19 +547,47 @@ host can't do, off the model didn't honour, a host refusing the thinking fields
464
547
  keeps the turn's `[level, ModelTarget]` and whether the generation thought; `turn_settings` and the stream handler
465
548
  call it. Turn thread only.
466
549
 
467
- ### Turn (Engine::Turn)
550
+ ### Turn and TurnContext
468
551
 
469
- The turn as the Engine sees it (the KernelLoop has its own `Turn` struct):
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.
470
555
 
471
556
  ```ruby
472
- Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
473
- :settings, :id, :limit, :resumed, :resume_from) do
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
474
562
  def resumed? = resumed ? true : false # a step-limit Continue
475
- def tag(event) = origin ? event.merge(origin: origin.to_h) : event # boundary events carry the origin's wire Hash
563
+ def tag(event) = origin.empty? ? event : event.merge(origin: origin.to_h) # boundary events carry the origin
476
564
  def elapsed = ... # seconds since started_at
477
565
  end
478
566
  ```
479
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).
586
+ - 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
+
480
591
  - `messages` is what the Engine sends: system head, history, reminders, the prompt. The loop works on its own
481
592
  copy; the Engine keeps `result.conversation` after a normal end or a Stop, and the error's partial
482
593
  conversation after a failure.
@@ -485,9 +596,10 @@ end
485
596
  - `on_event` is the turn's sink.
486
597
  - `ended` is set by `end_turn`.
487
598
  - The live conversation: both loops register the array they append to on the kernel
488
- (`KernelLoop#llm_context_turn`, `LLMContextForget::Turn#conversation`; `KernelLoop#start_turn`,
489
- `ChatLoop::Run#initialize`). `Engine#live_conversation` returns it while a turn runs (nil otherwise: the kernel
490
- never clears it). A worker's drain (`Worker#pending_input_drain`, on the turn's thread at every iteration
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
491
603
  boundary) copies it, with the lines that boundary merges, into `Session#turn_checkpoint` (`TurnCheckpoint`):
492
604
  every save while the turn runs writes that copy with an `interrupted_turn` mark instead of the session's
493
605
  messages, written at the first boundary and then at most every 10 s, so a killed worker loses at most that much
@@ -581,9 +693,10 @@ make theirs, and `Engine#run_turn`, the Worker and `GuardrailWiring` pass it on.
581
693
  `TurnOrigin#participant_author` does. The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry
582
694
  `origin` when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than
583
695
  the session's owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`).
584
- An owner's turn has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn`
585
- gets the origin, and `#turn_author` reads its participant author for the gate's checks; the guardrails `Context`
586
- (the hooks' `context`) keeps the wire Hash.
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.
587
700
 
588
701
  Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any author but none or `"owner"`) goes:
589
702
 
@@ -606,13 +719,14 @@ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any autho
606
719
  `Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
607
720
  `SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
608
721
  keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
609
- - **The gate**: `GuardrailWiring#participant_boundary` is the core check after the load failures, picked by
610
- `#turn_mode`: `Guardrails::PairBoundary` for a pair turn, `Guardrails::ReviewBoundary` for a review one. Both read
611
- `#turn_author` and are no-ops for the owner's turns; the review boundary denies a participant's turn everything
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
612
725
  outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
613
726
  `ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
614
727
  The declared tools stay the session's (D3: no rebuild per author).
615
- - **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise) is the hook
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
616
730
  runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
617
731
  plugin host's (`ctx.turn_author`).
618
732
  - **Spend**: the analytics turn record's `author` feeds `SessionMetrics#spend` / `spend_by_author` (the snapshot's
@@ -655,11 +769,14 @@ Engine initialization (in this order, among other things):
655
769
  declared per toolkit by Registry#declared? (a source registered at load, or a tool registered then that
656
770
  the toolkit leaves reachable: Registry::Index#loaded; Toolkit::Resolved#declares? treats built-ins and
657
771
  plugin tools alike: full declares both, another kit its list and adds) and
658
- sets call_tool's unwrap (Tools::ToolIndex: the chi group from Registry#reachable_names, the sources'
772
+ sets call_tool's unwrap (Tools::ToolIndex: the chi group from ToolSurface#index_only, the sources'
659
773
  groups, search, call checks, the unwrap; a source runs the call with its own plugin Context)
660
774
  @reminder_store = ReminderStore.new (the kernel gets it), @reminder_queue, @reminders
661
- wire_kernel: @kernel = KernelLoop.new(client:, profile:, hooks:, reminder_store:, tools:)
662
- @kernel.guardrail_gate = @guardrail_wiring.gate; @kernel.warmup = PromptWarmup.new
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=)
663
780
  @hooks.runtime = @hook_routes.runtime (HookRoutes, built first: what a hook or plugin does reaches the UIs)
664
781
  @native_backend = LLM::NativeBackend.new(kernel: @kernel)
665
782
  @prompt_builder = SystemPrompt.new(...)
@@ -672,7 +789,8 @@ Model switching, Engine#switch_model!(model_name, persist_default:, typed:):
672
789
  one assignment)
673
790
  → drops the given profile and the profile resolution (resolved again on first need; one that another
674
791
  thread finishes for the old model is neither kept nor applied: #store_profile)
675
- → sync_model_key!, @prompt_builder.reset! (the system prompt is built again)
792
+ → sync_model_key! (the next turn's TurnContext takes the keys), @prompt_builder.reset! (the system prompt is
793
+ built again)
676
794
  → sync_kernel_client! (the kernel talks to the new host), the context window cache dropped
677
795
  → the old model's ctx dropped: @metrics.forget_model_reports! (its window and profile reports) and
678
796
  @last_context_status = nil; /model (SessionCommands#switch_model) then calls #announce_switched_context!
@@ -729,7 +847,7 @@ loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7
729
847
  `MessageTime.gap_line` (10 min, `GAP_SECONDS`).
730
848
 
731
849
  `:question_asked` (`QuestionAsked`, `question_asked.rb`) fires from the two Engine seams where a question of the
732
- model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
850
+ model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `Services#question_handler`, after
733
851
  `AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
734
852
  (`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
735
853
  relayed approval (`Engine::RelayPeer#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
@@ -766,7 +884,9 @@ step's text ([architecture.md](../architecture.md), the `display_refs` paragraph
766
884
 
767
885
  ## Guardrails
768
886
 
769
- `ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
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);
770
890
  the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
771
891
  known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
772
892
  `event[:blocked]` flag. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are
@@ -884,18 +1004,19 @@ Paths are under `lib/samagotchi/`.
884
1004
  | `engine/model_settings.rb` | `Engine::ModelSettingsPart`: the session's model settings (SessionModelSettings) and the snapshots (EngineSnapshots), forwarded |
885
1005
  | `engine/events.rb` | `Engine::EventsPart`: observers, `#announce`, the event lock, held announcements, load events, the emit helpers |
886
1006
  | `engine/cards.rb` | `Engine::CardsPart`: `#show_card`, anytime commands and their threads |
887
- | `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives' view, proposals and audit |
1007
+ | `engine/objectives.rb` | `Engine::ObjectivesPart`: the objectives' stage builder, `announce_objectives` and `propose_objective` (forwarded), Check this's side request |
888
1008
  | `engine/messages.rb` | `Engine::MessagesPart`: the session messages API (out-of-turn edits, checkpoints, the live conversation) |
889
1009
  | `engine/plugins.rb` | `Engine::PluginsPart`: loading plugins, init tasks and staged tools, `Plugin::Host` |
890
1010
  | `engine/hooks.rb` | `Engine::HooksPart`: turn-scoped hooks, the hook runtime (HookRoutes, forwarded), reminders |
891
1011
  | `engine/guardrails.rb` | `Engine::GuardrailsPart`: the guardrails' readers and the first turn's load warnings |
892
1012
  | `engine/participants.rb` | `Engine::ParticipantsPart`: the turn's author, a pair turn's stop, the participants line |
893
1013
  | `engine/session.rb` | `Engine::SessionPart`: `#session=`, the state dir, the relay's view, the system prompt and its memory names |
894
- | `engine/verify.rb` | `Engine::VerifyPart`: Verify and Revise anchors at a turn's end, the verdict |
1014
+ | `engine/verify.rb` | `Engine::VerifyPart`: the Verify/Revise anchors' stage builder |
895
1015
  | `engine/questions.rb` | `Engine::QuestionsPart`: ask-user-question (QuestionDesk, forwarded) |
896
- | `engine/toolkit.rb` | `Engine::ToolkitPart`: the toolkit, `ctx.load_tools`, System 1's toolkit suggestion |
897
- | `engine/context_judge.rb` | `Engine::ContextJudgePart`: the attached-context candidate judge and its cards |
898
- | `engine/turn_helpers.rb` | `Engine::TurnHelpersPart`: a turn's settings, images, model ref, used memories, the turn-end warm-up |
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 |
899
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 |
900
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 |
901
1022
  | `engine_snapshots.rb` | `EngineSnapshots`: the UIs' read model, `session_state_snapshot` (cheap, never raises) and `stats_snapshot` (may probe `/props`); the Engine forwards |