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
@@ -2,152 +2,16 @@
2
2
 
3
3
  module Samagotchi
4
4
  class Engine
5
- # Verify and Revise anchors at a turn's end: the opening's stamp, a Revise
6
- # turn's draft checked, a Verify turn's quote checked against its source
7
- # session and its verdict recorded. No ivars of its own: it reads the turn,
8
- # and the sessions it loads through Engine methods.
5
+ # Verify and Revise anchors (TurnStages::VerifyAnchors): #begin_turn
6
+ # checks a Verify turn's claim, #end_turn's event step stamps the opening
7
+ # and records the verdict. Reads @verify_anchors.
9
8
  module VerifyPart
10
- # The turn's anchors, stamped on its opening message (the prompt, or a
11
- # wake turn's note) in the messages it keeps: display metadata, never
12
- # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
13
- # A Verify turn's claim however it ended; a Revise turn's draft only
14
- # once it completed with an answer (#revision_target). Returns the
15
- # stamped ReviseAnchor (the end event's revises), or nil.
16
- def stamp_opening(messages, turn, outcome)
17
- return unless messages.is_a?(Array)
9
+ private
18
10
 
19
- revises = revision_target(messages, turn, outcome)
20
- fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
21
- return if fields.empty?
11
+ # The anchors' stage (TurnStages::VerifyAnchors).
12
+ attr_reader :verify_anchors
22
13
 
23
- opening = messages.reverse.find do |message|
24
- Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
25
- end
26
- return unless opening
27
-
28
- opening.merge!(fields)
29
- revises
30
- end
31
- private :stamp_opening
32
-
33
- # The turn a Revise turn revised (D6: "this turn revised that one"):
34
- # only a completed turn with an answer (AnswerTail) has revised
35
- # anything. A draft no prompt in the messages carries is no target (a
36
- # stale page, a rolled-back turn); one a stamped turn already revised
37
- # is re-targeted to its stack's top, the newest version, which the
38
- # person meant ("this draft").
39
- def revision_target(messages, turn, outcome)
40
- return unless turn.revises && outcome == "completed"
41
-
42
- slice = AnswerTail.turn_slice(messages, turn.id)
43
- return unless slice&.any? { |message| AnswerTail.answer?(message) }
44
-
45
- target = turn.revises.turn_id
46
- seen = [turn.id]
47
- until seen.include?(target)
48
- return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
49
-
50
- seen << target
51
- newer = messages.reverse.find do |message|
52
- Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
53
- end
54
- return ReviseAnchor.new(turn_id: target) unless newer
55
-
56
- target = (newer[:turn_id] || newer["turn_id"]).to_s
57
- end
58
- reject_revise_anchor(turn.revises, :cycle)
59
- end
60
- private :revision_target
61
-
62
- def reject_revise_anchor(anchor, reason)
63
- Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
64
- nil
65
- end
66
- private :reject_revise_anchor
67
-
68
- def verified_anchor(session, anchor)
69
- return unless anchor
70
-
71
- source = verify_anchor_source_session(session, anchor)
72
- return unless source
73
-
74
- turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
75
- return reject_verify_anchor(anchor, :turn_missing) unless turn
76
-
77
- reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
78
- return reject_verify_anchor(anchor, :reply_missing) unless reply
79
-
80
- quote_text = verify_comparison_text(anchor.quote)
81
- quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
82
- return reject_verify_anchor(anchor, :quote_missing) unless quoted
83
-
84
- anchor
85
- end
86
- private :verified_anchor
87
-
88
- # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
89
- # pages show it: a page quotes its own text, which reads the session's
90
- # folder relative, the home folder as ~ and a secret shape as
91
- # [redacted] (a participant's page gets it so, Redact;
92
- # the owner's page rewrites its quote the same way, claim_link.js
93
- # normalizeClaim, so claim links match both pages). +source+ is the
94
- # session whose folder that is.
95
- def verify_quote_in?(quote_text, answer, source)
96
- return true if verify_comparison_text(answer).include?(quote_text)
97
-
98
- viewer = Redact::Viewer.new(cwd: source.working_directory)
99
- verify_comparison_text(Redact.text(answer, viewer)).include?(quote_text)
100
- end
101
- private :verify_quote_in?
102
-
103
- def verify_anchor_source_session(session, anchor)
104
- return session unless session.verify_fresh
105
-
106
- fresh = session.verify_fresh
107
- return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
108
- return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
109
-
110
- Session.load(session.parent_id, state_dir: session_state_dir)
111
- rescue ArgumentError, SystemCallError, IOError => e
112
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
113
- error: e.class.name)
114
- nil
115
- end
116
- private :verify_anchor_source_session
117
-
118
- def verify_comparison_text(text)
119
- OutputFormatter.strip_markup(text)
120
- .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
121
- .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
122
- .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
123
- .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
124
- .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
125
- .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
126
- .tr("|", " ") # a table row's cells; the page copies them without the pipes
127
- .gsub(/\s+/, " ")
128
- .strip
129
- end
130
- private :verify_comparison_text
131
-
132
- def reject_verify_anchor(anchor, reason)
133
- Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
134
- nil
135
- end
136
- private :reject_verify_anchor
137
-
138
- def record_verdict(turn, outcome)
139
- return unless outcome == "completed" && turn.verify
140
-
141
- answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
142
- parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
143
- return unless parsed
144
-
145
- record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
146
- by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
147
- turn.session.verdicts = turn.session.verdicts + [record]
148
- record
149
- end
150
- private :record_verdict
14
+ def build_verify_anchors = TurnStages::VerifyAnchors.new(state_dir: -> { session_state_dir })
151
15
  end
152
16
  end
153
17
  end
@@ -30,6 +30,7 @@ require_relative "kernel_loop"
30
30
  require_relative "tools/builtins"
31
31
  require_relative "tools/task_runtime"
32
32
  require_relative "tools/tool_index"
33
+ require_relative "tools/tool_surface"
33
34
  require_relative "peer_sources"
34
35
  require_relative "session_commands"
35
36
  require_relative "plugin/loader"
@@ -55,6 +56,11 @@ require_relative "objectives_audit"
55
56
  require_relative "owner_prompt"
56
57
  require_relative "side_judgments"
57
58
  require_relative "toolkit_suggest"
59
+ require_relative "turn_stages/context_judge"
60
+ require_relative "turn_stages/objectives"
61
+ require_relative "turn_stages/toolkit_suggestion"
62
+ require_relative "turn_stages/verify_anchors"
63
+ require_relative "turn_stages/warmup"
58
64
  require_relative "tool_loads"
59
65
  require_relative "answer_tail"
60
66
  require_relative "archive_store"
@@ -79,6 +85,7 @@ require_relative "tools/memory"
79
85
  require_relative "muted_memories"
80
86
  require_relative "used_memories"
81
87
  require_relative "turn_state"
88
+ require_relative "turn_context"
82
89
  require_relative "steer_cut"
83
90
  require_relative "engine_peers"
84
91
  require_relative "engine_snapshots"
@@ -206,8 +213,6 @@ module Samagotchi
206
213
  # steers) and the idle layer's activity clock. Built first: a plugin
207
214
  # may steer or ask whether a turn runs while it loads.
208
215
  @turn_state = TurnState.new(clock: -> { monotonic_now })
209
- @objective_proposals = []
210
- @objective_proposals_mutex = Mutex.new
211
216
  # A message for the running turn cutting its generation.
212
217
  @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
213
218
  cut_after: -> { steer_cut_after })
@@ -223,12 +228,11 @@ module Samagotchi
223
228
  @chat_backend_mutex = Mutex.new
224
229
  @side_judgments = SideJudgments.new
225
230
  @judgments = build_judgments
226
- # On mode's cards an answer brought while a turn ran: shown once it
227
- # has ended (#show_context_cards_after_turn).
228
- @context_cards_after_turn = []
229
- @context_cards_mutex = Mutex.new
230
- @toolkit_warmup_thread = nil
231
231
  @host_registry = host_registry || HostRegistry.new
232
+ # The turn stages over the objectives (its audit, view and proposals)
233
+ # and the Verify/Revise anchors (TurnStages).
234
+ @objectives_stage = build_objectives_stage
235
+ @verify_anchors = build_verify_anchors
232
236
  # A model given here (a worker's session model) is the one it runs,
233
237
  # so its host is checked; the config default is only checked once it
234
238
  # is used (the one-shot checks the model it starts on, #switch_model!).
@@ -302,6 +306,8 @@ module Samagotchi
302
306
  # The kernel (built here, or a spec's) and what it reads of the Engine;
303
307
  # then the native backend over it.
304
308
  wire_kernel(kernel, muted_memories)
309
+ # The turn-end warm-up's stage, over that kernel.
310
+ @turn_warmup = build_turn_warmup
305
311
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
306
312
  @resume_session = session_id ? Session.load(session_id) : nil
307
313
  @prompt_builder = build_system_prompt(memories, appended_prompt_files)
@@ -379,6 +385,11 @@ module Samagotchi
379
385
  # The session's toolkit: the kits (the plugins' too, chi.toolkit),
380
386
  # what its next turn declares, the turn-start apply.
381
387
  @toolkit_binding = build_toolkit_binding
388
+ # The turn stages over it and the side asks (TurnStages): System 1's
389
+ # toolkit suggestion and the attached-context candidate judge, which a
390
+ # plugin may reach as it loads.
391
+ @toolkit_suggestion = build_toolkit_suggestion
392
+ @context_judge = build_context_judge
382
393
  # The tools plugins publish without declaring (chi.tool_source), and
383
394
  # the search and call tools that reach them (Tools::ToolIndex).
384
395
  @tool_index = Tools::ToolIndex.new
@@ -396,6 +407,8 @@ module Samagotchi
396
407
  # with no plugins too: a toolkit's left-out tools are reachable
397
408
  # either way (declared per toolkit, Tools::Registry).
398
409
  @tool_index.declare(@tools, failures: @plugin_failures)
410
+ # What readers off the turn thread see until the first turn's start.
411
+ publish_tools!
399
412
  end
400
413
  private :build_tools_and_plugins
401
414
 
@@ -424,45 +437,39 @@ module Samagotchi
424
437
  end
425
438
  private :subscribe_session_observers
426
439
 
427
- # The kernel and what it holds of the Engine (stores, hooks, tools, the
428
- # mutes, the question flow, the gate, peers, warm-up, the hook runtime),
429
- # and the native backend over it. After the plugins load.
440
+ # The kernel and what it holds of the Engine for its life, one
441
+ # KernelLoop::Services (stores, hooks, tools, the mutes, the question
442
+ # flow, peers, the gate, warm-up, the generation guard), and the native
443
+ # backend over it. After the plugins load. Nothing per turn: a turn's
444
+ # TurnContext goes down the backend's complete (#generate).
430
445
  def wire_kernel(kernel, muted_memories)
431
- # A given kernel (specs) gets the Engine's store, hooks and tools, as
432
- # the one built here does.
433
- @kernel = kernel || KernelLoop.new(client: @client, profile: @model_binding.given_profile, model_name: effective_model_name,
434
- hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
435
- @kernel.reminder_store = @reminder_store
436
- @kernel.hooks = @hooks
437
- @kernel.tools = @tools
438
- sync_kernel_client!
439
- sync_model_key!
440
- # The mutes never change during a session, so no re-sync: the kernel's
441
- # memory_read guard reads the same list for every turn.
446
+ # The mutes never change during a session: the kernel's memory_read
447
+ # guard reads the same list for every turn.
442
448
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
443
- @kernel.muted_memory_names = @muted_memory_names
444
- # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
445
- @kernel.question_handler = proc { |payload| request_question(payload) }
446
- # Every tool call asks this gate first. The kernel is never rebuilt, so
447
- # it holds across model switches.
449
+ # Where the approvals live; also where list_sessions and send_note
450
+ # look for other sessions (a worker moves it, Worker).
448
451
  self.guardrail_state_dir = Session.default_state_dir
449
- # list_sessions and send_note speak for whichever session runs now.
450
- @kernel.peers = PeerView.new(self)
451
- # A plugin tool's row refs resolve against the session's folder, as
452
- # answer refs do (AnswerRefs), not the process's.
453
- @kernel.session_cwd = -> { @session&.working_directory }
454
- # forget_outputs refuses what the user pinned in the working set.
455
- @kernel.working_set_pins = -> { @session&.working_set_pins || [] }
456
- # A cut tool output is saved whole in the session's folder.
457
- @kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
458
- @kernel.guardrail_gate = @guardrail_wiring.gate
459
- @kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
460
- @kernel.participant_mode = -> { @guardrail_wiring.turn_mode }
461
- # A pair turn stops once its participant passed their cap.
462
- @kernel.generation_guard = -> { guard_participant_spend }
463
452
  # The turn-end warm-up; the kernel asks it for a slot pin per request.
464
453
  @prompt_warmup = PromptWarmup.new
465
- @kernel.warmup = @prompt_warmup
454
+ services = KernelLoop::Services.new(
455
+ hooks: @hooks, tools: @tools, reminder_store: @reminder_store, muted_memory_names: @muted_memory_names,
456
+ # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
457
+ question_handler: proc { |payload| request_question(payload) },
458
+ # list_sessions and send_note speak for whichever session runs now.
459
+ peers: PeerView.new(self),
460
+ # Every tool call asks this gate first. The kernel is never rebuilt,
461
+ # so it holds across model switches.
462
+ gate: @guardrail_wiring.gate(hooks: @hooks, tools: @tools),
463
+ warmup: @prompt_warmup,
464
+ # A pair turn stops once its participant passed their cap.
465
+ generation_guard: ->(context) { guard_participant_spend(context) }
466
+ )
467
+ # A given kernel (specs) gets the same services as the one built here.
468
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @model_binding.given_profile,
469
+ model_name: effective_model_name, services: services)
470
+ @kernel.attach(services) if kernel
471
+ sync_kernel_client!
472
+ sync_model_key!
466
473
  # What a hook can do beyond reading its event (event[:notify],
467
474
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
468
475
  @hooks.runtime = hook_runtime
@@ -476,11 +483,9 @@ module Samagotchi
476
483
  def build_guardrail_wiring
477
484
  GuardrailWiring.new(
478
485
  scratch: @scratch,
479
- hooks: -> { @hooks },
486
+ # Built before the tools (the hooks load into its failures).
480
487
  tools: -> { @tools },
481
488
  session: -> { @session },
482
- model_key: -> { @model_binding.model_key },
483
- model_name: -> { guardrail_model_name },
484
489
  cancelled: -> { !!active_cancel_controller&.cancelled? },
485
490
  # An approval of the model's call: :question_asked first (QuestionAsked).
486
491
  ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
@@ -495,7 +500,7 @@ module Samagotchi
495
500
  system_one_runs: -> { system_one_runs },
496
501
  cancel_controller: -> { active_cancel_controller },
497
502
  # A participant's turn: their name on the owner's card, their
498
- # grant's mode for the turn's (GuardrailWiring#turn_mode).
503
+ # grant's mode for the turn's (GuardrailWiring#begin_turn).
499
504
  participant: ->(author) { @participants.entry_for(author, session: @session) }
500
505
  )
501
506
  end
@@ -511,7 +516,8 @@ module Samagotchi
511
516
  thinking_notices: -> { @thinking_notices },
512
517
  metrics: -> { @metrics },
513
518
  hooks: -> { @hooks },
514
- kernel: -> { @kernel },
519
+ turn_context: -> { turn_context },
520
+ last_turn_context: -> { last_turn_context },
515
521
  model_settings: -> { @model_settings },
516
522
  session: -> { @session },
517
523
  live_conversation: -> { live_conversation },
@@ -683,7 +689,7 @@ module Samagotchi
683
689
  end
684
690
  @side_judgments.join(SIDE_JUDGMENTS_JOIN_SECONDS)
685
691
  # The warm-up's answer is dropped anyway.
686
- stop_toolkit_warmup
692
+ @toolkit_suggestion.stop_warmup
687
693
  left = threads.count(&:alive?)
688
694
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
689
695
  @plugin_tasks.kill_leftovers
@@ -715,13 +721,13 @@ module Samagotchi
715
721
  kept, event = yield(seconds)
716
722
  event[:turn_id] = turn.id
717
723
  event[:verify] = true if turn.verify
718
- revises = stamp_opening(kept, turn, outcome)
724
+ revises = @verify_anchors.stamp_opening(kept, turn, outcome)
719
725
  replace_session_messages(turn.session, kept) if kept
720
- verdict = record_verdict(turn, outcome)
726
+ verdict = @verify_anchors.record_verdict(turn, outcome)
721
727
  event[:verdict] = verdict if verdict
722
728
  event[:revises] = revises.to_h if revises
723
729
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
724
- announce_objectives(turn.session)
730
+ @objectives_stage.announce(turn.session)
725
731
  turn.session.status = Session::STATUS_IDLE
726
732
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
727
733
  emit_event(turn.on_event, turn.tag(event))
@@ -778,14 +784,15 @@ module Samagotchi
778
784
  end
779
785
  private :build_recap
780
786
 
781
- # One turn's values, for #run_turn and its endings. Per turn and
782
- # single-threaded: the cross-thread turn state (the cancel slot, the
783
- # sink, steers) stays on the Engine.
787
+ # One turn's lifecycle state, for #run_turn and its endings. Per turn
788
+ # and single-threaded: the cross-thread turn state (the published
789
+ # TurnContext, the cancel slot, the sink, steers) stays in TurnState.
790
+ # +context+: the turn's TurnContext, the one source of truth for its
791
+ # id, session, origin and controller (read through #id, #session,
792
+ # #origin, #controller); #generate replaces it with the settings in.
784
793
  # +ended+: the end event is out (what follows is post-turn work).
785
- # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
786
- # set on the kernel in #generate.
787
- # +id+: names the turn on :turn_started (its metrics record's id) and on
788
- # its saved prompt, so a UI pairs the two whatever left the conversation.
794
+ # +prepared_settings+: the LLM::TurnSettings #prepare_turn makes (before
795
+ # the hooks); #generate completes them into the context's.
789
796
  # +limit+: the iteration limit #generate ran it with.
790
797
  # +resumed+: a step-limit Continue (run_turn's resumed:).
791
798
  # +resume_from+: where its reasoning starts, the run it resumes
@@ -794,8 +801,15 @@ module Samagotchi
794
801
  # +wall+: the wall time it started (MessageTime.now, once): the
795
802
  # :turn_started event's at and its user message's (and reminders') stamp.
796
803
  # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
797
- Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
798
- :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
804
+ Turn = Struct.new(:context, :prompt, :continue, :on_event, :started_at, :messages, :ended, :prepared_settings,
805
+ :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
806
+ # The turn's id (:turn_started's turn_id, its metrics record's id and
807
+ # its saved prompt's), session, origin and controller: its context's.
808
+ def id = context.id
809
+ def session = context.session
810
+ def origin = context.origin
811
+ def controller = context.controller
812
+
799
813
  def resumed? = resumed ? true : false
800
814
 
801
815
  # The turn ended as a completed one (not canceled, not failed): the
@@ -866,7 +880,7 @@ module Samagotchi
866
880
  # wake's note) names it with this id
867
881
  # @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
868
882
  # @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
869
- # the prompt at the turn's end, metadata only (#stamp_opening)
883
+ # the prompt at the turn's end, metadata only (TurnStages::VerifyAnchors#stamp_opening)
870
884
  #
871
885
  # An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
872
886
  # session as after a Stop (the prompt, the loop's finished tool
@@ -897,7 +911,7 @@ module Samagotchi
897
911
  pending_input: pending_input)
898
912
  publish_used_memories(session, on_event)
899
913
  complete_turn(turn, result)
900
- warm_up_next_turn(turn, result)
914
+ @turn_warmup.after(turn, result)
901
915
  result
902
916
  rescue Interrupt => e
903
917
  # After the end event (in the after_turn/session_end hooks) the
@@ -924,24 +938,23 @@ module Samagotchi
924
938
  raise
925
939
  ensure
926
940
  release_turn(probe_cancel_before)
927
- show_context_cards_after_turn
928
- # Only a completed turn shows its proposal cards: a canceled, failed
929
- # or rolled-back turn keeps none, and its proposals are dropped here
930
- # so the next turn does not show them either.
931
- turn.completed? ? show_objective_proposals : discard_objective_proposals(turn)
941
+ @context_judge.show_cards_after_turn
942
+ # Only a completed turn shows its proposal cards; any other's are
943
+ # dropped, so the next turn does not show them either.
944
+ @objectives_stage.finally(turn)
932
945
  end
933
946
  end
934
947
 
935
948
  # The turn's state before anything can fail it: the session running,
936
- # the turn flag, its cancel controller and sink. The cross-thread state
937
- # is set here and cleared in #release_turn only.
949
+ # its TurnContext, the turn flag, its cancel controller and sink. The
950
+ # cross-thread state is set here and cleared in #release_turn only.
938
951
  def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
939
952
  input_waiting: nil)
940
953
  origin = TurnOrigin.of(origin)
941
954
  # A participant's Verify fresh child: every turn is theirs (their
942
955
  # author, boundary and cap), whoever started it.
943
956
  origin = session.verify_fresh.origin_for(origin) if session.respond_to?(:verify_fresh) && session.verify_fresh
944
- verify = verified_anchor(session, VerifyAnchor.from(verify))
957
+ verify = @verify_anchors.verified(session, VerifyAnchor.from(verify))
945
958
  # Track the active session for recap and status snapshot.
946
959
  @session = session
947
960
  # status is turn state: running now, idle again before the turn's end
@@ -950,25 +963,62 @@ module Samagotchi
950
963
  @used_memories.absorb(session)
951
964
  # Provide a cancellable controller for this turn (cross-process cancel via file flag)
952
965
  effective_controller = cancel_controller || CancellationController.new
953
- # The turn runs, with its controller and sink, in one step: a Stop
954
- # or a plugin's card from another thread never sees a running turn
955
- # without them. The idle recap detector does not fire (or render an
956
- # invalidated recap) while the model is working. A hook's notice goes
957
- # where the turn's events go (a one-shot prints only its sink; a
958
- # worker's observers carry it to the bridge).
959
- @turn_state.begin!(controller: effective_controller, sink: on_event, input_waiting: input_waiting)
966
+ # The turn's context, whole before the turn is published: who queued
967
+ # it, a participant's mode (participants.json), git asked afresh.
968
+ context = new_turn_context(session, origin, effective_controller, id || SecureRandom.uuid)
969
+ # The turn runs, with its context, controller and sink, in one step:
970
+ # a Stop, a plugin's card or its ctx.turn_author from another thread
971
+ # never sees a running turn without them (or with the last turn's).
972
+ # The idle recap detector does not fire (or render an invalidated
973
+ # recap) while the model is working. A hook's notice goes where the
974
+ # turn's events go (a one-shot prints only its sink; a worker's
975
+ # observers carry it to the bridge).
976
+ @turn_state.begin!(context: context, controller: effective_controller, sink: on_event, input_waiting: input_waiting)
960
977
  # Drop a recap already in flight: the turn makes it stale.
961
978
  @recap&.invalidate!
962
- # The gate's context: who queued this turn, and git asked afresh.
963
- @guardrail_wiring.begin_turn(origin)
964
979
 
965
- Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
966
- controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
967
- id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
968
- revises: ReviseAnchor.from(revises))
980
+ Turn.new(context: context, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
981
+ started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), wall: MessageTime.now,
982
+ verify: VerifyAnchor.from(verify), revises: ReviseAnchor.from(revises))
969
983
  end
970
984
  private :begin_turn
971
985
 
986
+ # A new turn's TurnContext (#begin_turn): the gate's turn facts
987
+ # (GuardrailWiring#begin_turn), the session's folders. Its settings and
988
+ # model come in #generate.
989
+ def new_turn_context(session, origin, controller, id)
990
+ start = @guardrail_wiring.begin_turn(origin, session: session)
991
+ TurnContext.new(id: id, session: session, origin: origin, author: origin.participant_author,
992
+ participant_mode: start.mode, participant: start.participant, controller: controller,
993
+ cwd: (session.working_directory if session.respond_to?(:working_directory)), session_dir: turn_session_dir(session),
994
+ state_dir: session_state_dir, git: start.git, settings: nil, model_key: nil,
995
+ fallback_model_key: nil, model_name: nil, surface: nil)
996
+ end
997
+ private :new_turn_context
998
+
999
+ # The session's folder, nil when it can't be named (an id that isn't
1000
+ # one, a lookup that raised): a cut tool output then says it wasn't saved.
1001
+ def turn_session_dir(session)
1002
+ Session.session_dir(session.id, state_dir: session_state_dir)
1003
+ rescue StandardError => e
1004
+ Log.warn(:turn, "tool_output_save_failed", error: e.class.name)
1005
+ nil
1006
+ end
1007
+ private :turn_session_dir
1008
+
1009
+ # The running turn's TurnContext as other threads see it (TurnState),
1010
+ # nil between turns; its settings are nil until #generate.
1011
+ # @return [TurnContext, nil]
1012
+ def turn_context = @turn_state.context
1013
+ private :turn_context
1014
+
1015
+ # The last finished turn's TurnContext (TurnState#last_context), nil
1016
+ # before any: its author (the recap's), its settings (the working set's
1017
+ # between turns).
1018
+ # @return [TurnContext, nil]
1019
+ def last_turn_context = @turn_state.last_context
1020
+ private :last_turn_context
1021
+
972
1022
  # Everything before the model is asked: :turn_started, the kernel's
973
1023
  # per-turn settings, the plugins' setup, the session_start/before_turn
974
1024
  # hooks, then the messages to send (#turn_messages). False when a Stop
@@ -987,14 +1037,14 @@ module Samagotchi
987
1037
  # A participant's turn names them (author, author_name) for the UIs'
988
1038
  # badge; the owner's turn_started stays as it was.
989
1039
  turn_started.merge!(author_fields(turn.origin.author, session: session),
990
- participant_mode_fields(turn.origin.author))
1040
+ participant_mode_fields(turn.context))
991
1041
  emit_event(turn.on_event, turn.tag(turn_started))
992
1042
  raise image_error if image_error
993
1043
 
994
1044
  # On mode's ask starts here too; the turn joins it before
995
1045
  # apply_toolkit! below, so its latency hides behind the probes and
996
1046
  # the init wait.
997
- toolkit_suggestion = start_toolkit_suggestion(turn)
1047
+ @toolkit_suggestion.start(turn)
998
1048
 
999
1049
  # The window and the profile probes run after run_turn's probe-cancel
1000
1050
  # swap, so a Stop cuts them; a failure ends the turn as turn_failed.
@@ -1006,12 +1056,12 @@ module Samagotchi
1006
1056
  refresh_profile!
1007
1057
 
1008
1058
  # Before anything of the turn is kept or a reminder is used up.
1009
- turn.settings = turn_settings(session)
1010
- refuse_images!(turn.settings.vision) unless image_refs.empty?
1059
+ turn.prepared_settings = turn_settings(session)
1060
+ refuse_images!(turn.prepared_settings.vision) unless image_refs.empty?
1011
1061
  # A Stop during the probes: the turn ends before the load-failure
1012
1062
  # warnings, which show once, so the next turn still shows them.
1013
1063
  if turn.controller.cancelled?
1014
- toolkit_suggestion&.abandon(:canceled)
1064
+ @toolkit_suggestion.abandon(turn, :canceled)
1015
1065
  return false
1016
1066
  end
1017
1067
 
@@ -1028,11 +1078,11 @@ module Samagotchi
1028
1078
  apply_staged_tools!
1029
1079
  # On mode: the suggested kit, before anything is declared (a Stop
1030
1080
  # during the wait is caught by the check below).
1031
- settle_toolkit_suggestion(turn, toolkit_suggestion)
1081
+ @toolkit_suggestion.settle(turn)
1032
1082
  # The candidate judge: its asks run beside the turn; on mode's
1033
1083
  # attaches reach the model at the next between-turn delivery.
1034
- start_context_judge(turn)
1035
- audit_objectives_incoming(turn)
1084
+ @context_judge.start(turn)
1085
+ @objectives_stage.incoming(turn)
1036
1086
  apply_toolkit!(session, turn.on_event)
1037
1087
  refresh_participants!(session)
1038
1088
  # A Stop during the waits above: the turn ends here,
@@ -1055,7 +1105,8 @@ module Samagotchi
1055
1105
  ensure
1056
1106
  # A turn that ended (a failure, an Interrupt) before it settled the
1057
1107
  # ask: its line says so. Nothing once it was settled.
1058
- toolkit_suggestion&.abandon(:failed)
1108
+ @toolkit_suggestion.abandon(turn, :failed)
1109
+ @toolkit_suggestion.finish(turn)
1059
1110
  end
1060
1111
  private :prepare_turn
1061
1112
 
@@ -1087,7 +1138,7 @@ module Samagotchi
1087
1138
 
1088
1139
  unless turn.continue
1089
1140
  user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
1090
- **participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
1141
+ **participant_mode_fields(turn.context), MessageTime::KEY => turn.at }
1091
1142
  user_message[:verify] = turn.verify.to_h if turn.verify
1092
1143
  user_message[:images] = image_refs unless image_refs.empty?
1093
1144
  turn.messages << user_message
@@ -1106,17 +1157,28 @@ module Samagotchi
1106
1157
  # Route model name as bare (without host prefix) to the server;
1107
1158
  # host selection already done via active client.
1108
1159
  bare_for_backend = bare_model_name(model.name)
1109
- # The turn's settings, set once here. The chat loop dispatches tools
1110
- # through the kernel without its #run: tag those dumps with this
1111
- # turn's model, not the last native one.
1160
+ # The turn's settings, set once here into its context (with the model
1161
+ # key and name the gate and memory_read use: frozen now, after the
1162
+ # hooks that may switch the model), which goes down the backend's
1163
+ # complete to every tool call, and is published (TurnState).
1112
1164
  explained = model.llm_context_explained(@model_settings.llm_context_session_args)
1113
1165
  model_backend = backend(model)
1114
1166
  # A host that loads tools runs a direct call of one the session
1115
1167
  # reaches but doesn't declare (ToolRunner, KernelLoop#dispatch).
1116
1168
  loads_tools = model_backend.respond_to?(:loads_tools?) && model_backend.loads_tools?(bare_for_backend)
1117
- @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
1118
- llm_context: explained&.resolved, price: model.price,
1119
- loads_tools: loads_tools)
1169
+ settings = turn.prepared_settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
1170
+ llm_context: explained&.resolved, price: model.price,
1171
+ loads_tools: loads_tools)
1172
+ # The turn's tools (Tools::ToolSurface), built once from the Registry
1173
+ # under those settings' layers and loads_tools: what the loops declare,
1174
+ # defer and dispatch until the turn ends; a change meanwhile (a
1175
+ # /toolkit change, a staged chi.replace_tools) is the next turn's.
1176
+ surface = Tools::ToolSurface.for(@tools, layers: settings.llm_context&.active_layers || [],
1177
+ loads_tools: loads_tools)
1178
+ turn.context = turn.context.with(settings: settings, model_key: @model_binding.model_key,
1179
+ fallback_model_key: @model_binding.fallback_model_key,
1180
+ model_name: guardrail_model_name, surface: surface)
1181
+ @turn_state.replace_context(turn.context)
1120
1182
  log_llm_context(explained)
1121
1183
 
1122
1184
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -1133,7 +1195,8 @@ module Samagotchi
1133
1195
  model_name: bare_for_backend,
1134
1196
  max_tool_output_chars: max_tool_output_chars,
1135
1197
  pending_input: turn_drain(pending_input),
1136
- reasoning_since: reasoning_since
1198
+ reasoning_since: reasoning_since,
1199
+ context: turn.context
1137
1200
  )
1138
1201
  result.run_start = run_start
1139
1202
  result
@@ -4,7 +4,7 @@ require_relative "objectives"
4
4
 
5
5
  module Samagotchi
6
6
  # The views of an Engine its peers get: the kernel's tools
7
- # (Engine#initialize sets @kernel.peers) and a delegate's approval relay
7
+ # (KernelLoop::Services#peers, Engine#wire_kernel) and a delegate's approval relay
8
8
  # (Engine#relay_peer).
9
9
  class Engine
10
10
  # The kernel's Tools::Peers, following the current session. cancelled?