samagotchi 0.153.0 → 0.154.1

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 (226) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +82 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +21 -8
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +4 -2
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-frontend.md +2 -2
  25. data/docs/internals/web-push.md +1 -1
  26. data/docs/memory.md +2 -2
  27. data/docs/plugins.md +23 -28
  28. data/docs/sessions.md +24 -24
  29. data/docs/sub-agent.md +2 -2
  30. data/docs/system-one.md +1 -1
  31. data/docs/testing.md +31 -6
  32. data/lib/samagotchi/answer_text.rb +33 -0
  33. data/lib/samagotchi/bridge/card_places.rb +56 -7
  34. data/lib/samagotchi/bridge/card_store.rb +24 -9
  35. data/lib/samagotchi/bridge.rb +52 -32
  36. data/lib/samagotchi/bridge_client.rb +19 -14
  37. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  38. data/lib/samagotchi/broadcast/triage.rb +1 -1
  39. data/lib/samagotchi/broadcast_command.rb +5 -5
  40. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  45. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  46. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  48. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  50. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  51. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  52. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  53. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  54. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  55. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  56. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  57. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  58. data/lib/samagotchi/child_move.rb +3 -3
  59. data/lib/samagotchi/commands/registry.rb +6 -6
  60. data/lib/samagotchi/composer_route.rb +4 -5
  61. data/lib/samagotchi/config.rb +14 -12
  62. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  63. data/lib/samagotchi/context_candidates.rb +4 -3
  64. data/lib/samagotchi/continue_offer.rb +1 -1
  65. data/lib/samagotchi/debug_log.rb +1 -1
  66. data/lib/samagotchi/degrade.rb +75 -0
  67. data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
  68. data/lib/samagotchi/engine/cards.rb +87 -0
  69. data/lib/samagotchi/engine/context_judge.rb +138 -0
  70. data/lib/samagotchi/engine/events.rb +131 -0
  71. data/lib/samagotchi/engine/guardrails.rb +87 -0
  72. data/lib/samagotchi/engine/hooks.rb +119 -0
  73. data/lib/samagotchi/engine/messages.rb +124 -0
  74. data/lib/samagotchi/engine/model_settings.rb +127 -0
  75. data/lib/samagotchi/engine/models.rb +261 -0
  76. data/lib/samagotchi/engine/objectives.rb +116 -0
  77. data/lib/samagotchi/engine/participants.rb +86 -0
  78. data/lib/samagotchi/engine/plugins.rb +208 -0
  79. data/lib/samagotchi/engine/questions.rb +98 -0
  80. data/lib/samagotchi/engine/recap.rb +130 -0
  81. data/lib/samagotchi/engine/session.rb +131 -0
  82. data/lib/samagotchi/engine/side_requests.rb +135 -0
  83. data/lib/samagotchi/engine/steering.rb +134 -0
  84. data/lib/samagotchi/engine/toolkit.rb +269 -0
  85. data/lib/samagotchi/engine/turn_end.rb +180 -0
  86. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  87. data/lib/samagotchi/engine/verify.rb +153 -0
  88. data/lib/samagotchi/engine.rb +62 -2646
  89. data/lib/samagotchi/engine_snapshots.rb +9 -8
  90. data/lib/samagotchi/grants.rb +575 -0
  91. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  92. data/lib/samagotchi/guardrails/context.rb +2 -3
  93. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  94. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  95. data/lib/samagotchi/hook_routes.rb +3 -3
  96. data/lib/samagotchi/idle_recap.rb +7 -6
  97. data/lib/samagotchi/idle_reminders.rb +3 -4
  98. data/lib/samagotchi/iteration_limit.rb +2 -2
  99. data/lib/samagotchi/kernel_loop.rb +13 -13
  100. data/lib/samagotchi/launch_mode.rb +13 -32
  101. data/lib/samagotchi/level_setting_command.rb +3 -2
  102. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  103. data/lib/samagotchi/llm_context_command.rb +3 -2
  104. data/lib/samagotchi/log.rb +7 -7
  105. data/lib/samagotchi/log_line.rb +1 -1
  106. data/lib/samagotchi/log_path.rb +1 -1
  107. data/lib/samagotchi/note_command.rb +1 -1
  108. data/lib/samagotchi/note_delivery.rb +18 -8
  109. data/lib/samagotchi/objectives.rb +24 -3
  110. data/lib/samagotchi/objectives_check.rb +8 -27
  111. data/lib/samagotchi/objectives_command.rb +4 -69
  112. data/lib/samagotchi/objectives_edit.rb +87 -0
  113. data/lib/samagotchi/output_formatter.rb +7 -1
  114. data/lib/samagotchi/owner_lock.rb +7 -1
  115. data/lib/samagotchi/parent_answers_command.rb +5 -4
  116. data/lib/samagotchi/participant_exposure.rb +42 -0
  117. data/lib/samagotchi/participant_mode.rb +1 -1
  118. data/lib/samagotchi/participant_name.rb +1 -1
  119. data/lib/samagotchi/participants.rb +17 -17
  120. data/lib/samagotchi/participants_watch.rb +1 -1
  121. data/lib/samagotchi/plugin/api.rb +3 -2
  122. data/lib/samagotchi/plugin/context.rb +21 -7
  123. data/lib/samagotchi/plugin/peer.rb +2 -1
  124. data/lib/samagotchi/plugin/sessions.rb +6 -4
  125. data/lib/samagotchi/plugin_tasks.rb +1 -2
  126. data/lib/samagotchi/private_files.rb +29 -0
  127. data/lib/samagotchi/project_env.rb +2 -2
  128. data/lib/samagotchi/question_desk.rb +8 -7
  129. data/lib/samagotchi/recap_command.rb +2 -1
  130. data/lib/samagotchi/recap_setup.rb +1 -1
  131. data/lib/samagotchi/recap_store.rb +1 -1
  132. data/lib/samagotchi/redact.rb +86 -0
  133. data/lib/samagotchi/reminder_queue.rb +2 -2
  134. data/lib/samagotchi/remote_attach_command.rb +1 -1
  135. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  136. data/lib/samagotchi/review_start.rb +1 -1
  137. data/lib/samagotchi/run_options.rb +6 -6
  138. data/lib/samagotchi/self_report.rb +42 -39
  139. data/lib/samagotchi/send_command/busy.rb +1 -1
  140. data/lib/samagotchi/send_command.rb +4 -1
  141. data/lib/samagotchi/session.rb +11 -2
  142. data/lib/samagotchi/session_access/contract.rb +3 -3
  143. data/lib/samagotchi/session_access/local.rb +9 -9
  144. data/lib/samagotchi/session_access/remote.rb +17 -7
  145. data/lib/samagotchi/session_access.rb +4 -4
  146. data/lib/samagotchi/session_archive_command.rb +2 -2
  147. data/lib/samagotchi/session_chain.rb +8 -6
  148. data/lib/samagotchi/session_commands.rb +45 -48
  149. data/lib/samagotchi/session_continue.rb +1 -1
  150. data/lib/samagotchi/session_delete_command.rb +4 -4
  151. data/lib/samagotchi/session_manager.rb +67 -49
  152. data/lib/samagotchi/session_metrics.rb +2 -2
  153. data/lib/samagotchi/session_model_settings.rb +3 -2
  154. data/lib/samagotchi/session_participants.rb +1 -1
  155. data/lib/samagotchi/session_retention.rb +27 -7
  156. data/lib/samagotchi/sessions_command.rb +4 -4
  157. data/lib/samagotchi/share_command.rb +10 -10
  158. data/lib/samagotchi/steer.rb +13 -2
  159. data/lib/samagotchi/stream_event.rb +22 -0
  160. data/lib/samagotchi/system_prompt.rb +154 -145
  161. data/lib/samagotchi/task_watch.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  163. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  164. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  166. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  167. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
  168. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  169. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  170. data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
  171. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  172. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  173. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  174. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  175. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  176. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  177. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  178. data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
  179. data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
  180. data/lib/samagotchi/terminal_ui.rb +142 -1387
  181. data/lib/samagotchi/title_command.rb +2 -2
  182. data/lib/samagotchi/tool_runner.rb +15 -16
  183. data/lib/samagotchi/toolkit_command.rb +2 -1
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/send_note.rb +2 -1
  189. data/lib/samagotchi/turn_endings.rb +2 -2
  190. data/lib/samagotchi/turn_flow.rb +11 -7
  191. data/lib/samagotchi/update_hint.rb +2 -2
  192. data/lib/samagotchi/version.rb +1 -1
  193. data/lib/samagotchi/web/app.rb +42 -51
  194. data/lib/samagotchi/web/message_display.rb +7 -0
  195. data/lib/samagotchi/web/participant_access.rb +2 -2
  196. data/lib/samagotchi/web/participant_review.rb +5 -5
  197. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  198. data/lib/samagotchi/web/participant_view.rb +2 -82
  199. data/lib/samagotchi/web/participants_admin.rb +1 -1
  200. data/lib/samagotchi/web/public/block_host.js +35 -8
  201. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  202. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  203. data/lib/samagotchi/web/public/cards_place.js +5 -3
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  207. data/lib/samagotchi/web/public/format.js +4 -3
  208. data/lib/samagotchi/web/public/turn_html.js +8 -4
  209. data/lib/samagotchi/web/push/store.rb +1 -1
  210. data/lib/samagotchi/web/session_access_routes.rb +13 -4
  211. data/lib/samagotchi/web/session_control.rb +12 -8
  212. data/lib/samagotchi/web/session_create.rb +1 -1
  213. data/lib/samagotchi/web/session_hub.rb +2 -2
  214. data/lib/samagotchi/web/session_show.rb +6 -2
  215. data/lib/samagotchi/web/session_summary.rb +1 -1
  216. data/lib/samagotchi/worker.rb +103 -9
  217. data/lib/samagotchi/worker_link.rb +3 -3
  218. data/lib/samagotchi/working_set_command.rb +3 -2
  219. data/lib/samagotchi.rb +0 -1
  220. metadata +31 -7
  221. data/lib/samagotchi/pending_input_queue.rb +0 -55
  222. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  223. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  224. data/lib/samagotchi/web/grants.rb +0 -575
  225. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  226. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Side requests: a plugin's ctx.ask_model and Check this on their own
6
+ # IdleClient, their usage in the session's side line, and System 1's
7
+ # recording of judged asks. Writes @side_client/@side_client_key (under
8
+ # @side_client_mutex); reads @metrics, @session, @host_registry.
9
+ module SideRequestsPart
10
+ # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
11
+ # on_usage) in the session's side line, apart from its turns' totals.
12
+ # Folded and announced as :side_usage under the event lock, so the side
13
+ # blocks reach the UIs in fold order (the lock order event lock →
14
+ # metrics mutex is the existing one); then, between turns, saved to
15
+ # analytics.json (a running turn's end or checkpoint saves it), unless
16
+ # the session is gone (no session file: a late recap thread must not
17
+ # recreate a deleted session's folder; the folder itself may not exist
18
+ # yet before a first turn). Safe from any thread: the recap's, a
19
+ # plugin's, the turn's.
20
+ # A side request counts against the participant who caused it
21
+ # (#side_author), its usage's own author when it names one.
22
+ # @param usage [SideUsage]
23
+ # @return [void]
24
+ def note_side_usage(usage)
25
+ usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
26
+ synchronize_events do
27
+ block = @metrics.add_side(usage)
28
+ @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
29
+ end
30
+ sid = @metrics.session_id
31
+ return if sid.nil? || turn_running?
32
+ return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
33
+
34
+ @metrics.persist(state_dir: session_state_dir)
35
+ end
36
+
37
+ # Who caused a side request of +kind+ now: the running turn's author (a
38
+ # plugin's ctx.ask_model in it), the last turn's for the idle recap
39
+ # (it recaps that turn); nil, the owner's, for anything else (an owner
40
+ # command's ask_model between turns).
41
+ # @return [String, nil]
42
+ def side_author(kind)
43
+ # Check this is only ever the owner's, whichever turn runs as it ends.
44
+ return nil if kind.to_s == ObjectivesCheck::SOURCE
45
+ return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
46
+
47
+ @guardrail_wiring.turn_author
48
+ end
49
+
50
+ private
51
+
52
+ # +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
53
+ # going wrong here leaves the client as it was: a run is never worth a
54
+ # judgment.
55
+ #
56
+ # A scratch session's asks aren't saved; a participant's turn is never
57
+ # judged, so it never comes here.
58
+ # @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
59
+ def system_one_recording(client, judgment, session, mode: nil)
60
+ return client if client.nil? || session&.scratch
61
+
62
+ context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
63
+ SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
64
+ rescue StandardError => e
65
+ Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
66
+ client
67
+ end
68
+ private :system_one_recording
69
+
70
+ # Where this Engine's runs are saved; read each time, as the settings are.
71
+ def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
72
+ private :system_one_runs
73
+
74
+ # ctx.ask_model's request: the session's current model on its host, as
75
+ # a turn resolves them (a /model switch counts), or a configured +model+
76
+ # (a host:id ref, an alias, a bare id one of a host's models), through its own
77
+ # IdleClient, so it shares nothing with the turn's backend. The client
78
+ # is kept while the target and timeout stay (what it learned, e.g. a
79
+ # refused thinking-off, holds for the next request); the session's id
80
+ # goes with the request. Its usage counts in the session's side line
81
+ # (#note_side_usage) under +source+, the asking bundle's name.
82
+ # @return [String] the answer
83
+ def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
84
+ target = model.nil? ? session_model_recap_target : configured_side_target(model)
85
+ client = side_model_client(target, timeout)
86
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
88
+ kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
89
+ Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
90
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
91
+ answer.text
92
+ end
93
+ private :ask_side_model
94
+
95
+ # The target of ctx.ask_model(model:): +model+ as a turn resolves it,
96
+ # once it is known to name something configured. IdleTarget.of_model
97
+ # never raises (an unknown host falls to the default host, an unknown id
98
+ # to the default entry), so a typo would silently ask another model.
99
+ # @raise [ArgumentError] +model+ names no configured host and is no
100
+ # host's model (cached, declared or saved lists; nothing is asked)
101
+ def configured_side_target(model)
102
+ ref = @host_registry.model_ref(model)
103
+ unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
104
+ raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
105
+ "or a model id one of your hosts lists)"
106
+ end
107
+
108
+ IdleTarget.of_model(@host_registry, model)
109
+ end
110
+ private :configured_side_target
111
+
112
+ # The IdleClient for +target+ at +timeout+: the last one when both are
113
+ # the same, else a new one.
114
+ def side_model_client(target, timeout)
115
+ @side_client_mutex.synchronize do
116
+ key = [target, timeout]
117
+ unless @side_client && @side_client_key == key
118
+ @side_client = IdleClient.for(target, timeout: timeout)
119
+ @side_client_key = key
120
+ end
121
+ @side_client
122
+ end
123
+ end
124
+ private :side_model_client
125
+
126
+ # The session's current model as a recap target: its host's OpenAI API
127
+ # (native llama.cpp hosts serve /v1/chat/completions too), key variable
128
+ # and bare model name, as a turn resolves them.
129
+ # @return [IdleTarget]
130
+ def session_model_recap_target
131
+ IdleTarget.of_model(@host_registry, effective_model_name)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The running turn as other threads see it: steering into it (now or
6
+ # at the next turn), the drains, the cut, the activity clock and the
7
+ # cancel. Reads @turn_state and @steer_cut; writes nothing of its own.
8
+ module SteeringPart
9
+ # Record that activity happened (user input or a completed turn): the
10
+ # idle jobs' seam (TurnState#record_activity). Each call advances both
11
+ # the last-activity timestamp and the activity sequence.
12
+ # @param now [Float, nil] injectable monotonic time (defaults to now)
13
+ def record_activity(now = nil)
14
+ @turn_state.record_activity(now)
15
+ end
16
+
17
+ # @return [Float] monotonic seconds of the last recorded activity
18
+ def last_activity_at
19
+ @turn_state.last_activity_at
20
+ end
21
+
22
+ # @return [Integer] monotonically-increasing activity counter (advanced by
23
+ # #record_activity; lets the idle detector summarize once per idle window)
24
+ def activity_seq
25
+ @turn_state.activity_seq
26
+ end
27
+
28
+ # @return [Boolean] true while a turn is in flight (the idle jobs never
29
+ # fire, nor render, while the model is generating)
30
+ def turn_running?
31
+ @turn_state.running?
32
+ end
33
+
34
+ # Put +text+ into the running turn, as a UI's steering does: it joins the
35
+ # conversation at the loop's next iteration boundary as its own user
36
+ # message (kind: "steer", source:). Callable from any thread; never
37
+ # blocks. With no turn running it does nothing (it never starts one).
38
+ # True means queued, not merged: at the after-answer boundary, and when
39
+ # the turn ends first, it is dropped (logged).
40
+ # @return [Boolean] whether it was queued
41
+ def steer(text, source:)
42
+ @turn_state.steer(text, source: source)
43
+ end
44
+
45
+ # A message for the running turn from +source+ cuts the generation
46
+ # streaming only thinking (SteerCut#cut_for_steer). Call it after the
47
+ # message is queued, with the #input_epoch read before queueing it.
48
+ # @return [Boolean] whether a generation was cut now
49
+ def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
50
+
51
+ # The drains that took input so far: read it before queueing a message
52
+ # for #cut_for_steer.
53
+ def input_epoch = @steer_cut.input_epoch
54
+
55
+ # The running generation's phase (SteerCut#generation_snapshot): what
56
+ # Bridge#snapshot puts in current_turn.generation.
57
+ def generation_snapshot = @steer_cut.generation_snapshot
58
+
59
+ # Like #steer, for the turn that begins next (a continue turn answered
60
+ # with a text): it joins that turn at its first boundary. Callable from
61
+ # any thread; a turn that never begins keeps it for the next one, unless
62
+ # #drop_carried_steers drops it.
63
+ # @return [Boolean] whether it was queued (false: blank)
64
+ def steer_next_turn(text, source:)
65
+ @turn_state.steer_next_turn(text, source: source)
66
+ end
67
+
68
+ # Drop the #steer_next_turn steers no turn began with (the continue turn
69
+ # failed before it began), logged; a no-op once a turn took them.
70
+ def drop_carried_steers
71
+ log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
72
+ end
73
+
74
+ # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
75
+ # in a --non-interactive run) and the plugin steers. At the after-answer
76
+ # boundary the steers are dropped: the model answered, and a nudge would
77
+ # only restart the turn. The steer part fails on its own, never taking
78
+ # the user's lines with it. Items are a String (a user line), a
79
+ # Steer::Line (a worker's input line with its sender) or a steer Hash.
80
+ def turn_drain(pending_input)
81
+ lambda do |at_answer: false|
82
+ lines = pending_input ? Array(pending_input.call) : []
83
+ # The input went to the model: a message that waited to cut is in.
84
+ @steer_cut.delivered! unless lines.empty?
85
+ lines + take_steers(at_answer)
86
+ end
87
+ end
88
+ private :turn_drain
89
+
90
+ def take_steers(drop)
91
+ steers = @turn_state.take_steers
92
+ return steers unless drop
93
+
94
+ log_dropped_steers(steers, "answered")
95
+ []
96
+ rescue StandardError => e
97
+ Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
98
+ []
99
+ end
100
+ private :take_steers
101
+
102
+ def log_dropped_steers(steers, why)
103
+ steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
104
+ end
105
+ private :log_dropped_steers
106
+
107
+ # @return [CancellationController, nil] active turn's cancellation controller
108
+ def active_cancel_controller
109
+ @turn_state.controller
110
+ end
111
+
112
+ # Whether a message a person sent waits for the running turn's next
113
+ # boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
114
+ # returns on it, an execute hands its command over early
115
+ # (PeerView#input_waiting?). False outside a turn, for a turn given no
116
+ # probe, and when the probe raises (warned once a turn; the tool never
117
+ # fails for it).
118
+ def input_waiting?
119
+ probe = @turn_state.input_waiting
120
+ !!probe&.call
121
+ rescue StandardError => e
122
+ Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
123
+ false
124
+ end
125
+
126
+ # Cancel the currently running turn, if any.
127
+ # @param reason [Symbol] cancellation reason
128
+ # @return [Boolean] whether a cancellation was triggered
129
+ def cancel_current_turn!(reason = :manual)
130
+ @turn_state.cancel!(reason)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's toolkit: the kits and what a turn declares
6
+ # (ToolkitBinding, forwarded), ctx.load_tools and the staged loads a turn
7
+ # appends, and System 1's toolkit suggestion (start, settle, warm-up).
8
+ # Writes @toolkit_warmup_thread; reads @toolkit_binding, @tools,
9
+ # @tool_index, @tool_loads, @side_judgments, @session.
10
+ module ToolkitPart
11
+ private
12
+
13
+ # The session's toolkit (ToolkitBinding): the kits, what its next turn
14
+ # declares, ctx.use_toolkit and the turn-start apply.
15
+ def toolkit_kits(errors = []) = @toolkit_binding.kits(errors)
16
+ def resolved_toolkit(session, errors = []) = @toolkit_binding.resolved(session, errors)
17
+ def toolkit_explained(toolkit = nil) = @toolkit_binding.explained(toolkit)
18
+ def toolkit_summary = @toolkit_binding.summary
19
+
20
+ # The tools this session registered (Tools::Registry), declared or not.
21
+ attr_reader :tools
22
+
23
+ public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
24
+
25
+ # A plugin's ctx.load_tools: stage the reachable tools +names+ names
26
+ # (Tools::ToolIndex#deferred_name) that the session neither declares
27
+ # nor loaded with an earlier tool change, for bundle +bundle+. A name
28
+ # that names no tool at all is logged; a declared or removed one is
29
+ # skipped quietly.
30
+ # @return [Array<String>] the wire names staged
31
+ def load_tools(names, bundle)
32
+ added = ToolLoads.added(@session&.messages)
33
+ wanted = names.filter_map do |name|
34
+ wire = @tool_index.deferred_name(name)
35
+ Log.info(:plugins, "load_tools_skipped", bundle: bundle, tool: name) unless wire || @tools.key?(name)
36
+ wire
37
+ end
38
+ @tool_loads.stage(wanted - added, by: bundle)
39
+ end
40
+ public :load_tools
41
+
42
+ # The staged tool loads, appended to +turn+'s messages (after its user
43
+ # message, or the trailing results and notes of a turn with none) as
44
+ # one ToolLoads entry per bundle, when the turn's host takes tool
45
+ # changes (ChatLoop#tool_changes?). Elsewhere they are dropped, logged.
46
+ # A conversation that ends with a reply keeps them staged (a system
47
+ # change must follow a user turn).
48
+ def append_tool_loads(turn)
49
+ return unless @tool_loads.any?
50
+ return if turn.messages.last&.dig(:role).to_s == "model"
51
+
52
+ model = effective_model
53
+ host = backend(model)
54
+ staged = @tool_loads.take
55
+ added = ToolLoads.added(turn.messages)
56
+ unless host.respond_to?(:tool_changes?) && host.tool_changes?(bare_model_name(model.name))
57
+ Log.info(:plugins, "tool_loads_not_sent", tools: staged.values.flatten.join(","), model: model.name.to_s)
58
+ return
59
+ end
60
+
61
+ staged.each do |bundle, names|
62
+ names -= added
63
+ next if names.empty?
64
+
65
+ turn.messages << ToolLoads.entry(names, by: bundle)
66
+ hook_notify("tools loaded: #{names.join(", ")}", :info, bundle)
67
+ end
68
+ end
69
+ private :append_tool_loads
70
+
71
+ # A plugin's ctx.use_toolkit (ToolkitBinding#use).
72
+ # @return [Boolean] whether it was staged
73
+ # @raise [ArgumentError] an unknown toolkit
74
+ def use_toolkit(name) = @toolkit_binding.use(name)
75
+ public :use_toolkit
76
+
77
+ # At a turn's start (ToolkitBinding#apply!).
78
+ # @return [Boolean] whether the declared tools changed
79
+ def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
80
+ private :apply_toolkit!
81
+
82
+ # Start the toolkit classifier after :turn_started, for audit and on
83
+ # mode alike (one eligibility, one snapshot, one thread). All session
84
+ # and tool-registry reads happen here; the thread receives only this
85
+ # snapshot. Audit never blocks: the thread logs its line. On mode
86
+ # returns the ask's Pending, which the turn joins before apply_toolkit!
87
+ # (#settle_toolkit_suggestion), with the turn's controller as the
88
+ # request's cancel controller.
89
+ # @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
90
+ def start_toolkit_suggestion(turn)
91
+ mode = Config.get("toolkit.suggest.mode").to_s
92
+ return if mode == "off"
93
+
94
+ joined = mode == "on"
95
+ session = turn.session
96
+ default = Toolkit.of(session.toolkit)
97
+ log = toolkit_suggestion_log
98
+ # The predicate checks the session first; the kits (memory kits are
99
+ # read from disk) and the log (read whole) only when it gets there.
100
+ kits = nil
101
+ candidates = nil
102
+ return unless ToolkitSuggest.eligible?(
103
+ session: session, prompt: turn.prompt, author: turn.origin.author,
104
+ candidate_count: lambda {
105
+ kits = @toolkit_binding.kits
106
+ candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
107
+ candidates.size
108
+ },
109
+ last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
110
+ defer_turns: Config.get("toolkit.suggest.defer_turns"),
111
+ defer_tokens: Config.get("toolkit.suggest.defer_tokens")
112
+ )
113
+
114
+ # One ask at a time: an eligible turn while the last one still runs
115
+ # goes unasked, and says so.
116
+ if @side_judgments.in_flight?(:toolkit)
117
+ Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
118
+ return
119
+ end
120
+
121
+ client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit),
122
+ ToolkitSuggest::JUDGMENT, session, mode: Config.get("toolkit.suggest.mode").to_s)
123
+ return unless client
124
+
125
+ # The warm-up's only value is the cache: the real ask never queues
126
+ # behind it.
127
+ stop_toolkit_warmup
128
+
129
+ # Rules, not tool lists: this runs before await_init_tasks and
130
+ # apply_staged_tools!, so a first turn's plugin/MCP tools aren't
131
+ # registered yet; the report expands the rules (ToolkitSuggest.rules).
132
+ snapshots = candidates.map do |kit|
133
+ ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
134
+ rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
135
+ end.freeze
136
+ default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
137
+ rules: ToolkitSuggest.rules(default.resolve(kits)))
138
+ registered = @tools.entries(toolkit: nil).reject { |entry| entry.source == Tools::ToolIndex::SOURCE }
139
+ core_tools, plugin_tools = registered.partition(&:core?).map { |entries| entries.map { |entry| entry.name.dup.freeze }.freeze }
140
+ state = ToolkitSuggest.question(
141
+ default: default, kits: kits, project: File.basename(session.project_root.to_s),
142
+ prompt: turn.prompt
143
+ ).last
144
+ snapshot = ToolkitSuggest::Snapshot.new(
145
+ session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
146
+ default: default_snapshot, candidates: snapshots, core_tools: core_tools, plugin_tools: plugin_tools,
147
+ state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
148
+ )
149
+ threshold = Config.get("toolkit.suggest.threshold")
150
+ runs = session.scratch ? nil : system_one_runs
151
+ pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
152
+ acted: runs && ->(judged, did) { runs.acted(judged.fields[:run_id], did) },
153
+ timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
154
+ # On mode: cut by a Stop while the turn waits (Pending#wait), not
155
+ # by one after it went on; audit's ask is never cut.
156
+ controller = joined ? pending.cancel_controller : nil
157
+ @side_judgments.start(:toolkit, warn: ToolkitSuggest.method(:warn), finish: pending.method(:finish)) do
158
+ ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
159
+ cancel_controller: controller)
160
+ end
161
+ joined ? pending : nil
162
+ rescue StandardError => e
163
+ ToolkitSuggest.warn(e)
164
+ nil
165
+ end
166
+ private :start_toolkit_suggestion
167
+
168
+ # On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
169
+ # suggestion) up to System 1's timeout from its start, or until a Stop,
170
+ # and put the session on a candidate picked with confidence ≥ threshold
171
+ # (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
172
+ # other outcome (the default, below threshold, undecided, no judgment,
173
+ # a timeout, a Stop) leaves the session on its default. Nothing here
174
+ # sends a request; prepare_turn's cancel check ends a stopped turn. A
175
+ # Stop wins over an answer already in (Pending#wait checks it first).
176
+ def settle_toolkit_suggestion(turn, pending)
177
+ return if pending.nil?
178
+
179
+ outcome, judged = pending.wait(turn.controller)
180
+ if outcome == :timeout
181
+ # The session starts on its default without a word otherwise: a
182
+ # slow System 1 host (a local one) is the usual cause.
183
+ timeout_ms = SystemOne::Client.configured_timeout_ms
184
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
185
+ reason: "System 1 gave no answer within system_one.timeout_ms")
186
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
187
+ return
188
+ end
189
+ unless outcome == :answered
190
+ Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
191
+ return
192
+ end
193
+ return if judged.nil?
194
+
195
+ pending.record(toolkit_suggestion_outcome(turn.session, judged))
196
+ rescue StandardError => e
197
+ ToolkitSuggest.warn(e)
198
+ pending&.record("failed")
199
+ end
200
+ private :settle_toolkit_suggestion
201
+
202
+ # What the turn does with an answered ask, as the log line's did:
203
+ # suggested (switched), chosen (the toolkit isn't the default any
204
+ # more), unavailable (the kit is gone), defer or default.
205
+ def toolkit_suggestion_outcome(session, judged)
206
+ case judged.would
207
+ when "suggested"
208
+ return "chosen" unless session.toolkit_origin == "default"
209
+ return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
210
+
211
+ switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
212
+ deferred: ToolkitSuggest.model_reply?(session.messages))
213
+ switched ? "suggested" : "chosen"
214
+ when "defer" then "defer"
215
+ when "no_judgment"
216
+ # System 1 failed (its own warn) or answered nothing usable.
217
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
218
+ "default"
219
+ else "default"
220
+ end
221
+ end
222
+ private :toolkit_suggestion_outcome
223
+
224
+ # On mode, as an idle worker starts (Worker#start_background): one ask
225
+ # with the real question and a stand-in prompt, on its own thread,
226
+ # never logged and never waited for, so System 1's server has the
227
+ # question's prefix cached for the first prompt. Only for a session a
228
+ # first prompt could be asked for: origin default, no model reply, not
229
+ # a delegate child or a review session, at least two candidates; and
230
+ # not when the worker starts on a first prompt (that ask comes at once).
231
+ def warm_toolkit_suggestion!
232
+ return unless Config.get("toolkit.suggest.mode").to_s == "on"
233
+
234
+ session = @session
235
+ return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
236
+ return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
237
+
238
+ default = Toolkit.of(session.toolkit)
239
+ kits = @toolkit_binding.kits
240
+ return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
241
+
242
+ client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
243
+ return unless client
244
+
245
+ project = File.basename(session.project_root.to_s)
246
+ @toolkit_warmup_thread = Thread.new do
247
+ ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
248
+ end
249
+ rescue StandardError => e
250
+ ToolkitSuggest.warn(e)
251
+ nil
252
+ end
253
+ public :warm_toolkit_suggestion!
254
+
255
+ # A still-running warm-up, stopped: a real ask (or the worker leaving)
256
+ # doesn't wait behind it.
257
+ def stop_toolkit_warmup
258
+ @toolkit_warmup_thread&.kill
259
+ @toolkit_warmup_thread = nil
260
+ end
261
+ private :stop_toolkit_warmup
262
+
263
+ def toolkit_suggestion_log
264
+ SideJudgments.log(ToolkitSuggest.log_path(session_state_dir), tag: :toolkit, event: "toolkit_suggest_log_failed")
265
+ end
266
+ private :toolkit_suggestion_log
267
+ end
268
+ end
269
+ end