samagotchi 0.154.1 → 0.156.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "builtin_calls"
4
+ require_relative "deferred_tools"
5
+ require_relative "registry"
6
+
7
+ module Samagotchi
8
+ module Tools
9
+ # What one turn's loops see of the session's tools, as one frozen value:
10
+ # what the request declares, what call_tool reaches, what the toolkit
11
+ # leaves out (and why), and the dispatch decision for a call. Built once
12
+ # per turn in Engine#generate from the Registry (its only input), where
13
+ # the turn's LLM context layers and its host's loads_tools are known,
14
+ # and carried as TurnContext#surface: KernelLoop#dispatch, ChatLoop's
15
+ # tool_definitions/deferred_tools and ToolRunner's left-out, unwrap
16
+ # and refusal checks read it. A change made while the turn runs (a
17
+ # /toolkit change, chi.replace_tools: staged anyway) shapes the next
18
+ # turn's surface only. Outside a turn a caller builds one (.for): a bare
19
+ # kernel or loop at its own context (KernelLoop#own_context), the tool
20
+ # index at load and at #refresh!, the readers off the turn thread from
21
+ # ToolkitBinding#published.
22
+ #
23
+ # +declared+ the entries the request declares, in prompt order (Registry#entries)
24
+ # +index_only+ the entries the toolkit leaves out that call_tool reaches (Registry#reachable_names)
25
+ # +left_out+ {name => why} for every tool the toolkit leaves out (Registry#left_out_names),
26
+ # in declaration order: :indexed (call_tool reaches it) or :user_only (only
27
+ # /toolkit add brings it back: removed, or nothing reaches it)
28
+ # +removed+ the names the toolkit removes (Registry#removed_names: remove:, --no-tool,
29
+ # /toolkit remove), in declaration order
30
+ # +stranded+ the names of the plugin tools registered after the index was declared that
31
+ # the toolkit leaves out and nothing reaches (Registry#stranded_names)
32
+ # +registered+ {name => entry} of every registered tool, declared or not
33
+ # +toolkit+ the session's Toolkit::Resolved (nil: full)
34
+ # +reaching_kit+ group → the kit that reaches it (Registry#reaching_kit), for a refusal's hint
35
+ # +index+ the tool index's declaration (Registry::Index), nil without one
36
+ # +layers+ the turn's LLM context layers
37
+ # +loads_tools+ whether the turn's host loads tools (TurnSettings#loads_tools)
38
+ class ToolSurface < Data.define(:declared, :index_only, :left_out, :removed, :stranded, :registered, :toolkit,
39
+ :reaching_kit, :index, :layers, :loads_tools)
40
+ # @param registry [Registry, nil] nil: no tools at all
41
+ # @param layers [Array<Symbol>] the turn's LLM context layers
42
+ # @param loads_tools [Boolean] the turn's host's (TurnSettings#loads_tools)
43
+ # @param toolkit [Toolkit::Resolved, nil] the toolkit to read the
44
+ # Registry under: its own (the session's) by default; another one
45
+ # for what a change would declare (ToolkitBinding#explained)
46
+ # @return [ToolSurface]
47
+ def self.for(registry, layers: [], loads_tools: false, toolkit: registry&.toolkit)
48
+ layers = Array(layers)
49
+ return empty(layers, loads_tools) if registry.nil?
50
+
51
+ reachable = registry.reachable_names(layers: layers, toolkit: toolkit)
52
+ new(declared: registry.entries(layers: layers, toolkit: toolkit),
53
+ index_only: reachable.map { |name| registry[name] },
54
+ left_out: registry.left_out_names(layers: layers, toolkit: toolkit)
55
+ .to_h { |name| [name, reachable.include?(name) ? :indexed : :user_only] },
56
+ removed: registry.removed_names(layers: layers, toolkit: toolkit),
57
+ stranded: registry.stranded_names(layers: layers, toolkit: toolkit),
58
+ registered: registry.registered.to_h { |entry| [entry.name, entry] },
59
+ toolkit: toolkit, reaching_kit: registry.reaching_kit, index: registry.index,
60
+ layers: layers, loads_tools: loads_tools == true)
61
+ end
62
+
63
+ # Its lists frozen (copies of the ones given), whichever way it is
64
+ # made (.for, .new, #with): no reader can change a turn's surface.
65
+ # The entries in them stay the Registry's own.
66
+ def initialize(declared:, index_only:, left_out:, registered:, layers:, removed: [], stranded: [], **)
67
+ super(declared: frozen_copy(Array(declared)), index_only: frozen_copy(Array(index_only)),
68
+ left_out: frozen_copy(left_out.to_h), removed: frozen_copy(Array(removed)),
69
+ stranded: frozen_copy(Array(stranded)), registered: frozen_copy(registered.to_h),
70
+ layers: frozen_copy(Array(layers)), **)
71
+ end
72
+
73
+ # The turn's surface (+context+'s: an Engine turn's, or a bare kernel's
74
+ # or loop's own, KernelLoop#own_context); a context without one raises
75
+ # MissingSurface (no reader falls back to the Registry as it is now).
76
+ # @param context [TurnContext]
77
+ # @return [ToolSurface]
78
+ def self.of(context) = context.surface || raise(MissingSurface)
79
+
80
+ # A bare kernel's or loop's own +context+ (no Engine turn: a spec, a
81
+ # bench) with a surface: its own, else one built now from +registry+
82
+ # under its layers and loads_tools. Only their own default context
83
+ # goes through here (KernelLoop#own_context, ChatLoop#turn_context);
84
+ # a context handed down is read as it is (.of).
85
+ # @param context [TurnContext]
86
+ # @return [TurnContext]
87
+ def self.bare(context, registry)
88
+ return context if context.surface
89
+
90
+ context.with(surface: self.for(registry, layers: context.llm_context_layers,
91
+ loads_tools: context.settings&.loads_tools == true))
92
+ end
93
+
94
+ def self.empty(layers, loads_tools)
95
+ new(declared: [], index_only: [], left_out: {}, removed: [], stranded: [], registered: {}, toolkit: nil,
96
+ reaching_kit: nil, index: nil, layers: layers, loads_tools: loads_tools == true)
97
+ end
98
+ private_class_method :empty
99
+
100
+ # @return [Array<String>] the declared tools' names, in prompt order
101
+ def declared_names = declared.map(&:name)
102
+
103
+ # @return [Array<Hash>] the declared tools' schemas, in prompt order
104
+ def schemas = declared.map(&:schema)
105
+
106
+ # The declared entry +name+ names (the one a call dispatches to), nil
107
+ # otherwise.
108
+ # @return [Registry::Entry, nil]
109
+ def declared_entry(name) = declared.find { |entry| entry.name == name.to_s }
110
+
111
+ # The entry +name+ names when call_tool reaches it (#index_only).
112
+ # @return [Registry::Entry, nil]
113
+ def indexed_entry(name) = index_only.find { |entry| entry.name == name.to_s }
114
+
115
+ # Whether the toolkit leaves +name+'s real tool out (#left_out).
116
+ def left_out?(name) = left_out.key?(name.to_s)
117
+
118
+ # Whether any tool, declared or not, is registered as +name+.
119
+ def registered?(name) = registered.key?(name.to_s)
120
+
121
+ # The entry a call runs on, nil when it can't run in this turn: one
122
+ # reached through call_tool (via:) only while the toolkit reaches it
123
+ # that way; any other by its name when declared, or, on a host that
124
+ # loads tools, when call_tool reaches it.
125
+ # @param call [Hash] the normalized call ({name:, via:, …})
126
+ # @return [Registry::Entry, nil]
127
+ def entry_for(call)
128
+ name = call[:name]
129
+ return indexed_entry(name) if call[:via]
130
+
131
+ declared_entry(name) || (loads_tools ? indexed_entry(name) : nil)
132
+ end
133
+
134
+ # Shell-like names (the harness's is `execute`). The unknown-tool error
135
+ # never echoes the name the model used: a model that sees its own wrong
136
+ # name again calls it again (a steer that named `bash` raised one
137
+ # model's bash rate from 9% to 30%); the activity event and the logs
138
+ # keep it.
139
+ SHELL_LIKE_TOOL_NAMES = %w[
140
+ bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
141
+ ].freeze
142
+
143
+ # The error a call #entry_for has no entry for gets back. A shell-like
144
+ # name with `execute` declared points at `execute`; every other unknown
145
+ # name lists what is declared. Neither echoes the wrong name.
146
+ #
147
+ # A tool the session's toolkit leaves out is a real one, so its error
148
+ # does name it (deliberately, against the rule above): the model learns
149
+ # it exists, and how to reach it (call_tool, when the toolkit reaches
150
+ # it that way) or that only the user can load it. A call reached
151
+ # through call_tool (+via+) of a tool the toolkit declares is told to
152
+ # call it directly: the model's call_tool(tool: "chi/read") with read
153
+ # declared, or a hook's rewrite (a direct call can't carry via:).
154
+ # @return [String]
155
+ def unknown_tool_error(name, via: nil)
156
+ names = declared_names
157
+ in_toolkit = " in this session#{" (toolkit #{toolkit.name})" if toolkit}"
158
+ if via && declared_entry(name)
159
+ "Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
160
+ elsif !via && indexed_entry(name)
161
+ "Error: #{name} isn't declared#{in_toolkit}; call it with #{BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
162
+ "(find_tools gives its inputSchema). Available: #{names.join(", ")}"
163
+ elsif left_out?(name)
164
+ "Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
165
+ "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
166
+ elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
167
+ "Error: no such tool. Shell commands run with `execute` (same arguments)."
168
+ else
169
+ "Error: no such tool. Available: #{names.join(", ")}"
170
+ end
171
+ end
172
+
173
+ # The tools a request on a host that loads tools declares deferred,
174
+ # +conversation+ (the request's, engine format) naming the ones it
175
+ # loaded (Tools::DeferredTools.list). The index's source tools are
176
+ # asked now (a late MCP server joins the list mid-turn, as before).
177
+ # @return [Array<DeferredTools::Deferred>]
178
+ def deferred_for(conversation) = DeferredTools.list(self, conversation)
179
+
180
+ private
181
+
182
+ # +list+ itself when frozen, else a frozen copy (the caller's stays
183
+ # as it is).
184
+ def frozen_copy(list) = list.frozen? ? list : list.dup.freeze
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turn_origin"
4
+ require_relative "steer"
5
+ require_relative "guardrails/context"
6
+ require_relative "llm/turn_settings"
7
+
8
+ module Samagotchi
9
+ # What one turn's tool path reads of it, as one frozen value: built by
10
+ # Engine#begin_turn before the turn is published (TurnState#begin!),
11
+ # handed explicitly down the loops (ModelBackend#complete's context:,
12
+ # KernelLoop#run, ChatLoop#complete), ToolResponse.run_batch,
13
+ # ToolRunner#run, Guardrails::Gate#evaluate and KernelLoop#dispatch_tool_call
14
+ # (its ToolContext). Lifetime: one turn. The source of truth for the
15
+ # turn's id, session, origin and controller (Engine::Turn keeps only the
16
+ # lifecycle state and reads these from it).
17
+ #
18
+ # +id+ the turn's id (:turn_started's turn_id)
19
+ # +session+ the Session it runs in (live: its working-set pins are read at call time)
20
+ # +origin+ its TurnOrigin (TurnOrigin::NONE for the owner's)
21
+ # +author+ the participant's author ("p:<id>") when one wrote it, else nil
22
+ # +participant_mode+ a participant's turn's mode (ParticipantMode: :review, :pair), nil for the owner's
23
+ # +participant+ that participant's Participants::Entry (participants.json), nil for none
24
+ # +controller+ the turn's CancellationController (live: its cancel state); a thread
25
+ # off the turn cancels through TurnState#controller, never through this
26
+ # +cwd+ the session's working directory (nil: the process's, Dir.pwd)
27
+ # +session_dir+ the session's folder (a cut tool output is saved there), nil for none
28
+ # +state_dir+ the sessions' state dir
29
+ # +git+ the turn's Guardrails::GitInfo (asked once per directory per turn)
30
+ # +settings+ the turn's LLM::TurnSettings: nil until Engine#generate sets them
31
+ # +model_key+, +fallback_model_key+ the memory overlay keys (memory_read, memory_write,
32
+ # rules' models:), +model_name+ the bare model name the gate's rules match: frozen at
33
+ # Engine#generate with the settings (a before_turn hook may switch the model)
34
+ # +surface+ the turn's Tools::ToolSurface (what its loops declare, defer and dispatch):
35
+ # nil until Engine#generate builds it; a reader given none raises Tools::MissingSurface
36
+ # (ToolSurface.of), a bare kernel's or loop's own context builds one (ToolSurface.bare)
37
+ #
38
+ # Only the turn thread replaces it (TurnState#replace_context), one frozen
39
+ # value per write; other threads read the published one (Engine#turn_context,
40
+ # Engine#last_turn_context).
41
+ TurnContext = Data.define(:id, :session, :origin, :author, :participant_mode, :participant, :controller, :cwd,
42
+ :session_dir, :state_dir, :git, :settings, :model_key, :fallback_model_key, :model_name,
43
+ :surface) do
44
+ # A context outside an Engine turn (a bare kernel, a spec, the bundle
45
+ # test kit's Gate): no session, the owner's, no controller, a fresh
46
+ # GitInfo, the settings given (LLM::TurnSettings.none by default).
47
+ # @return [TurnContext]
48
+ def self.none(**fields)
49
+ new(id: nil, session: nil, origin: TurnOrigin::NONE, author: nil, participant_mode: nil, participant: nil,
50
+ controller: nil, cwd: nil, session_dir: nil, state_dir: nil, git: Guardrails::GitInfo.new,
51
+ settings: LLM::TurnSettings.none, model_key: nil, fallback_model_key: nil, model_name: nil, surface: nil,
52
+ **fields)
53
+ end
54
+
55
+ # Whether a participant wrote the turn (Steer.participant_author?).
56
+ def participant? = Steer.participant_author?(author)
57
+
58
+ # Whether the turn's controller is cancelled (a Stop, a hook's stop_turn).
59
+ def cancelled? = !!controller&.cancelled?
60
+
61
+ # The turn's LLM context layers ([] without settings or a strategy): the
62
+ # tools they add are the turn's to offer and dispatch.
63
+ def llm_context_layers = settings&.llm_context&.active_layers || []
64
+
65
+ # The session's working-set pins (Session#working_set_pins), read now.
66
+ def working_set_pins = session.respond_to?(:working_set_pins) ? Array(session.working_set_pins) : []
67
+ end
68
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../context_candidate_choice"
5
+ require_relative "../context_candidates"
6
+ require_relative "../context_candidates_log"
7
+ require_relative "../context_judge"
8
+ require_relative "../context_sources"
9
+ require_relative "../log"
10
+ require_relative "../owner_prompt"
11
+ require_relative "../session"
12
+ require_relative "../side_judgments"
13
+ require_relative "../system_one/client"
14
+
15
+ module Samagotchi
16
+ module TurnStages
17
+ # The attached-context candidate judge's stage (Samagotchi::ContextJudge
18
+ # is the judge itself; inside this class the bare name is this stage):
19
+ # its pass at an owner turn's start and for a late candidate, the asks it
20
+ # starts, and the cards an answer brought while a turn ran, shown once it
21
+ # has ended. Its state is those held cards, under their mutex.
22
+ #
23
+ # Its two callables are read on the asks' thread, after the call that
24
+ # started the asks returned (an answer comes when it comes): which
25
+ # session the Engine is on then, and the card shown where cards go then.
26
+ class ContextJudge
27
+ # @param side_judgments [SideJudgments] the Engine's side asks (:context kind)
28
+ # @param host_registry [HostRegistry] System 1's client resolves its host here
29
+ # @param turn_state [TurnState] whether a turn runs as an answer comes (#running?)
30
+ # @param show_card [#call] (**card) shows a card (Engine#show_card: EventRoutes, on the caller's thread)
31
+ # @param current_session [#call] the session the Engine is on as an answer comes (nil: none)
32
+ def initialize(side_judgments:, host_registry:, turn_state:, show_card:, current_session:)
33
+ @side_judgments = side_judgments
34
+ @host_registry = host_registry
35
+ @turn_state = turn_state
36
+ @show_card = show_card
37
+ @current_session = current_session
38
+ # On mode's cards an answer brought while a turn ran: shown once it
39
+ # has ended (#show_cards_after_turn).
40
+ @cards_after_turn = []
41
+ @cards_mutex = Mutex.new
42
+ end
43
+
44
+ # For specs: the asks' thread (SideJudgments' :context kind), nil before any.
45
+ def asks_thread = @side_judgments.thread(:context)
46
+
47
+ # The candidate judge at an owner turn's start, after the toolkit's
48
+ # ask settled: the session's candidates' status changes are logged
49
+ # (ContextCandidatesLog#look) and, unless context.candidates.mode is
50
+ # off, the proposed candidates due an ask are asked about the owner's
51
+ # prompts so far, this one included. Never a participant's turn, a
52
+ # review session or a Continue (no new prompt). The sessions dir is
53
+ # the turn's (TurnContext#state_dir).
54
+ # @param recorder [SystemOne::Recorder] the asks' runs (built for this call)
55
+ def start(turn, recorder:)
56
+ session = turn.session
57
+ return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
58
+
59
+ pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn",
60
+ state_dir: turn.context.state_dir, recorder: recorder)
61
+ end
62
+
63
+ # A late proposal no owner prompt mentions (Plugin::Host
64
+ # #candidate_proposed: github-pr's init task runs beside the first
65
+ # turn): judged on the owner's prompts so far, the running turn's too.
66
+ # The caller (Engine#judge_late_candidates) gives the session the
67
+ # Engine is on, never nil nor a review session.
68
+ # @param messages [Array<Hash>] the owner's conversation so far (Engine#plugin_messages)
69
+ # @param recorder [SystemOne::Recorder] the asks' runs (built for this call)
70
+ # @param state_dir [String] the sessions dir
71
+ def late_candidates(session, messages:, recorder:, state_dir:)
72
+ texts = ContextCandidates.owner_texts(messages)
73
+ pass(session, texts, phase: "late", state_dir: state_dir, recorder: recorder) unless texts.empty?
74
+ end
75
+
76
+ # The cards #choice held while a turn ran (Engine#run_turn's ensure,
77
+ # after the turn flag is released).
78
+ def show_cards_after_turn
79
+ cards = @cards_mutex.synchronize { @cards_after_turn.slice!(0..) }
80
+ cards.each { |card| @show_card.call(**card) }
81
+ rescue StandardError => e
82
+ Samagotchi::ContextJudge.warn(e)
83
+ end
84
+
85
+ private
86
+
87
+ # One look at +session+'s candidates; the asks go to SideJudgments'
88
+ # :context kind, one thread asking each due candidate in turn.
89
+ def pass(session, texts, phase:, state_dir:, recorder:)
90
+ return if session.id.nil? || session.scratch
91
+
92
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
93
+ return if rows.empty?
94
+
95
+ log = ContextCandidatesLog.log_for(state_dir)
96
+ records = ContextCandidatesLog.records_for(log, session.id)
97
+ ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
98
+ settings = Samagotchi::ContextJudge::Settings.from_config
99
+ return if settings.mode == "off"
100
+
101
+ jobs = Samagotchi::ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings,
102
+ phase: phase, parent_attached: parent_attached(session, state_dir))
103
+ return if jobs.empty?
104
+
105
+ client = recorder.wrap(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
106
+ Samagotchi::ContextJudge::JUDGMENT, session, mode: settings.mode)
107
+ return unless client
108
+
109
+ start_asks(client, jobs, log, settings, choice(session, log, settings, state_dir))
110
+ rescue StandardError => e
111
+ Samagotchi::ContextJudge.warn(e)
112
+ end
113
+
114
+ # @return [Thread, nil] the asks' thread, nil when none started
115
+ def start_asks(client, jobs, log, settings, choice)
116
+ if @side_judgments.in_flight?(:context)
117
+ Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
118
+ return nil
119
+ end
120
+
121
+ @side_judgments.start(:context, warn: Samagotchi::ContextJudge.method(:warn)) do
122
+ jobs.each { |job| choice.call(job, Samagotchi::ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
123
+ end
124
+ end
125
+
126
+ # What an answer does on the asks' thread (ContextCandidateChoice
127
+ # #after_judged: on mode attaches or cards; audit nothing). Taken on
128
+ # the caller's thread. The attach only writes the session's folder: its
129
+ # note comes with the next between-turn delivery, never into a running
130
+ # turn. A card shows only while the Engine is still on that session,
131
+ # and not in a running turn (it isn't the turn's: no "waiting for you"
132
+ # on it), but once it has ended.
133
+ # @return [#call] (job, fields)
134
+ def choice(session, log, settings, state_dir)
135
+ session_id = session.id
136
+ project_root = session.project_root
137
+ show = lambda do |**card|
138
+ current = @current_session.call
139
+ next nil unless current.nil? || current.id == session_id
140
+
141
+ @cards_mutex.synchronize do
142
+ next @cards_after_turn << card if @turn_state.running?
143
+
144
+ @show_card.call(**card)
145
+ end
146
+ end
147
+ lambda do |job, fields|
148
+ ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
149
+ state_dir: state_dir, log: log, show_card: show)
150
+ end
151
+ end
152
+
153
+ # A delegate child's parent's sources, by title (else name): the
154
+ # judge's started_from. nil for any other session (a chi send --new
155
+ # session has no parent; a fork isn't a delegate).
156
+ def parent_attached(session, state_dir)
157
+ return nil unless session.delegate? && session.parent_id
158
+
159
+ parent_root = if Session.exist?(session.parent_id, state_dir: state_dir)
160
+ Session.load(session.parent_id, state_dir: state_dir).project_root
161
+ else
162
+ session.project_root
163
+ end
164
+ ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: state_dir)
165
+ .map { |attached| attached.source.title || attached.name }
166
+ rescue StandardError => e
167
+ Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
168
+ []
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../log"
5
+ require_relative "../objectives"
6
+ require_relative "../objectives_audit"
7
+ require_relative "../owner_prompt"
8
+ require_relative "../system_one/client"
9
+
10
+ module Samagotchi
11
+ module TurnStages
12
+ # The session's objectives over a turn (Samagotchi::Objectives is the
13
+ # list itself; inside this class the bare name is this stage): System 1's
14
+ # audit of what comes in at a turn's start and what goes out at its end
15
+ # (ObjectivesAudit), the view announced when it changed, and the model's
16
+ # proposals kept as cards until the turn is over. Its state is those
17
+ # proposals under their mutex and the last view announced (under the
18
+ # event lock); the audit is built per call.
19
+ class Objectives
20
+ # @param side_judgments [SideJudgments] the Engine's side asks (the audit's kinds)
21
+ # @param host_registry [HostRegistry] System 1's client resolves its host here
22
+ def initialize(side_judgments:, host_registry:)
23
+ @side_judgments = side_judgments
24
+ @host_registry = host_registry
25
+ # The last view announced (#announce), under the event lock.
26
+ @announced = nil
27
+ # The model's proposals until the turn is over (#finally).
28
+ @proposals = []
29
+ @proposals_mutex = Mutex.new
30
+ end
31
+
32
+ # System 1 on the objectives, in audit (ObjectivesAudit), at a turn's
33
+ # start: the owner's turns only, and for a message a person's: a
34
+ # reminder's or a delegate's prompt isn't one.
35
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this call)
36
+ def incoming(turn, recorder:)
37
+ return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
38
+
39
+ audit(turn.context.state_dir, recorder).incoming(turn.session, turn.prompt)
40
+ end
41
+
42
+ # The turn's answer, after the end event and before :after_turn (the
43
+ # caller skips a canceled or exhausted turn): the owner's turns only.
44
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this
45
+ # call: after a before_turn hook's model switch, the new model)
46
+ def outgoing(turn, result, recorder:)
47
+ return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
48
+
49
+ audit(turn.context.state_dir, recorder).outgoing(turn.session, result.text)
50
+ end
51
+
52
+ # A note added between turns (Engine#add_context_note): incoming too.
53
+ # @param recorder [SystemOne::Recorder] the audit's runs (built for this call)
54
+ # @param state_dir [String] the sessions dir
55
+ def note(session, note, recorder:, state_dir:) = audit(state_dir, recorder).note(session, note)
56
+
57
+ # The session's objectives as the page draws them (Objectives.view), when
58
+ # that differs from the last announced: after an edit (its file written,
59
+ # pending), once a list note is in, at a turn's end (its moves) and after
60
+ # a rollback. A session with no list announces nothing. The caller
61
+ # (Engine#announce_objectives) holds the event lock, the one guard of
62
+ # the last view, announces the event and logs a failure: it only
63
+ # informs, never fails a turn.
64
+ # @param state_dir [String] the sessions dir
65
+ # @param running [Array<Hash>, nil] the running turn's conversation (Engine#live_conversation), nil between turns
66
+ # @return [Hash, nil] the :objectives_updated event to announce, nil when the view is the same
67
+ def announce(session, state_dir:, running:)
68
+ view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: state_dir, running: running&.dup)
69
+ return if view.nil? || view == @announced
70
+
71
+ @announced = view
72
+ { type: :objectives_updated, session_id: session.id, **view }
73
+ end
74
+
75
+ # A change the model proposes (the objectives tool), kept until the turn
76
+ # is over, as TurnStages::ContextJudge's cards are: a card shown then is a quiet one, between turns, with its Apply kept
77
+ # over a restart (CardStore), not a running turn's that says it waits on the
78
+ # user. The same change twice in a turn is one card.
79
+ # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
80
+ def propose(proposal)
81
+ @proposals_mutex.synchronize do
82
+ next true if @proposals.any? { |p| p.line == proposal.line }
83
+ next false if @proposals.size >= Samagotchi::Objectives::PROPOSALS_PER_TURN
84
+
85
+ @proposals << proposal
86
+ true
87
+ end
88
+ end
89
+
90
+ # However +turn+ ended (Engine#run_turn's ensure, after the turn flag is
91
+ # released): only a completed turn shows its proposal cards. A canceled,
92
+ # failed or rolled-back turn keeps none, and its proposals are dropped
93
+ # here so the next turn does not show them either: their Apply card
94
+ # would sit after a turn whose `objectives propose` call is not in the
95
+ # history (a rollback after Ctrl-C drops it). The caller
96
+ # (Engine#show_objective_proposals) shows the cards.
97
+ # @return [Array<Hash>] the completed turn's cards (Engine#show_card's
98
+ # arguments); none for any other ending
99
+ def finally(turn)
100
+ return proposal_cards if turn.completed?
101
+
102
+ discard_proposals(turn)
103
+ []
104
+ end
105
+
106
+ # For specs: the audit's asks' thread for +kind+ (ObjectivesAudit's
107
+ # SideJudgments kinds), nil before any.
108
+ def audit_thread(kind) = @side_judgments.thread(kind)
109
+
110
+ # For specs: the proposals kept for the running turn (a copy).
111
+ def proposals = @proposals_mutex.synchronize { @proposals.dup }
112
+
113
+ private
114
+
115
+ def proposal_cards
116
+ drain_proposals.map do |proposal|
117
+ id = "objectives-#{SecureRandom.hex(4)}"
118
+ { source: Samagotchi::Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
119
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
120
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }] }
121
+ end
122
+ end
123
+
124
+ def discard_proposals(turn)
125
+ dropped = drain_proposals
126
+ Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
127
+ end
128
+
129
+ def drain_proposals
130
+ @proposals_mutex.synchronize { @proposals.slice!(0..) }
131
+ end
132
+
133
+ # The audit for one call, over the values it is given: nothing of it
134
+ # is kept. Its client is asked for only once an active objective is
135
+ # there (ObjectivesAudit#ask), and with saving off nothing would come
136
+ # of an ask, so none is made.
137
+ def audit(state_dir, recorder)
138
+ ObjectivesAudit.new(
139
+ side: @side_judgments, state_dir: state_dir,
140
+ client_for: lambda do |judgment, session|
141
+ next nil unless recorder.saving?
142
+
143
+ recorder.wrap(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
144
+ judgment, session, mode: nil)
145
+ end
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end