samagotchi 0.120.0 → 0.121.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +34 -11
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +6 -2
  12. data/docs/internals/prompt-caching.md +7 -6
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/plugins.md +28 -8
  16. data/docs/sessions.md +15 -0
  17. data/docs/testing.md +44 -2
  18. data/lib/samagotchi/answer_command.rb +1 -1
  19. data/lib/samagotchi/bridge.rb +8 -8
  20. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  21. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  22. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  23. data/lib/samagotchi/bridge_client.rb +52 -65
  24. data/lib/samagotchi/bridge_secret.rb +9 -7
  25. data/lib/samagotchi/bundle_command/check.rb +27 -2
  26. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
  28. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  30. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  35. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/engine.rb +1 -0
  39. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  41. data/lib/samagotchi/guardrails_command.rb +41 -6
  42. data/lib/samagotchi/image_store.rb +6 -3
  43. data/lib/samagotchi/interrupted_turn.rb +6 -3
  44. data/lib/samagotchi/kernel_loop.rb +5 -0
  45. data/lib/samagotchi/live_versions.rb +6 -7
  46. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  47. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  48. data/lib/samagotchi/plugin/api.rb +14 -8
  49. data/lib/samagotchi/plugin/sessions.rb +13 -4
  50. data/lib/samagotchi/push_command.rb +1 -1
  51. data/lib/samagotchi/relay_verifier.rb +2 -3
  52. data/lib/samagotchi/relay_watcher.rb +2 -2
  53. data/lib/samagotchi/remote_attach_command.rb +1 -1
  54. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  55. data/lib/samagotchi/reply_wait.rb +4 -3
  56. data/lib/samagotchi/review_command.rb +1 -1
  57. data/lib/samagotchi/run_options.rb +1 -0
  58. data/lib/samagotchi/session.rb +4 -0
  59. data/lib/samagotchi/session_access/local.rb +18 -8
  60. data/lib/samagotchi/session_access/remote.rb +5 -0
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  62. data/lib/samagotchi/session_access.rb +21 -9
  63. data/lib/samagotchi/session_continue.rb +2 -3
  64. data/lib/samagotchi/session_manager.rb +118 -28
  65. data/lib/samagotchi/sessions_command.rb +10 -5
  66. data/lib/samagotchi/system_one/client.rb +6 -1
  67. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  68. data/lib/samagotchi/system_one/log.rb +21 -6
  69. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  72. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  73. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  74. data/lib/samagotchi/tool_declarations.rb +1 -1
  75. data/lib/samagotchi/tool_runner.rb +37 -1
  76. data/lib/samagotchi/toolkit.rb +117 -27
  77. data/lib/samagotchi/toolkit_binding.rb +32 -0
  78. data/lib/samagotchi/toolkit_command.rb +23 -0
  79. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  80. data/lib/samagotchi/tools/delegate.rb +1 -1
  81. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  82. data/lib/samagotchi/tools/registry.rb +20 -6
  83. data/lib/samagotchi/tools/tool_index.rb +103 -20
  84. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +123 -65
  87. data/lib/samagotchi/web/participant_access.rb +26 -7
  88. data/lib/samagotchi/web/participant_review.rb +11 -6
  89. data/lib/samagotchi/web/participant_view.rb +7 -3
  90. data/lib/samagotchi/web/participants_admin.rb +13 -0
  91. data/lib/samagotchi/web/public/app.js +1 -1
  92. data/lib/samagotchi/web/public/forms.css +12 -0
  93. data/lib/samagotchi/web/public/index.html +7 -5
  94. data/lib/samagotchi/web/public/level_chip.js +2 -2
  95. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  96. data/lib/samagotchi/web/public/notifications.js +21 -1
  97. data/lib/samagotchi/web/public/participant.html +4 -2
  98. data/lib/samagotchi/web/public/participant.js +34 -11
  99. data/lib/samagotchi/web/public/participants_panel.js +49 -14
  100. data/lib/samagotchi/web/public/push.js +4 -3
  101. data/lib/samagotchi/web/public/push_devices.js +17 -3
  102. data/lib/samagotchi/web/public/question_card.js +15 -1
  103. data/lib/samagotchi/web/public/question_cards.js +11 -2
  104. data/lib/samagotchi/web/public/review_history.js +24 -4
  105. data/lib/samagotchi/web/public/review_start.js +1 -3
  106. data/lib/samagotchi/web/public/session_actions.js +14 -2
  107. data/lib/samagotchi/web/public/storage.js +16 -0
  108. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  109. data/lib/samagotchi/web/push/client.rb +5 -0
  110. data/lib/samagotchi/web/push/store.rb +16 -3
  111. data/lib/samagotchi/web/push_routes.rb +3 -2
  112. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  113. data/lib/samagotchi/web/session_create.rb +4 -4
  114. data/lib/samagotchi/web/session_hub.rb +6 -4
  115. data/lib/samagotchi/web/session_summary.rb +8 -16
  116. data/lib/samagotchi/web/tunnel.rb +10 -1
  117. data/lib/samagotchi/worker.rb +1 -1
  118. data/lib/samagotchi/worker_link.rb +213 -0
  119. data/lib/samagotchi/worker_sidecar.rb +15 -37
  120. metadata +4 -1
@@ -116,8 +116,10 @@ module Samagotchi
116
116
  errors = []
117
117
  resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
118
118
  warn_unresolved(session, resolved, errors, on_event)
119
+ warn_unknown_groups(resolved, on_event)
119
120
  resolved = nil if resolved.everything?
120
121
  tools = @tools.call
122
+ tools.reaching_kit = ->(group) { reaching_kit(group) }
121
123
  return false if resolved == tools.toolkit
122
124
 
123
125
  tools.toolkit = resolved
@@ -126,6 +128,19 @@ module Samagotchi
126
128
  true
127
129
  end
128
130
 
131
+ # The first kit that resolves and reaches source group +group+, the
132
+ # user's own (config's, the plugins') before the built-ins, so a hint
133
+ # names "/toolkit web" rather than "full"; nil when none does.
134
+ def reaching_kit(group)
135
+ kits = self.kits
136
+ builtin = Toolkit::BUILTIN_KITS.keys
137
+ kits.keys.sort_by.with_index { |name, i| [builtin.include?(name) ? 1 : 0, i] }.find do |name|
138
+ Toolkit.resolve_kit(name, kits).reaches_group?(group)
139
+ rescue Toolkit::Unresolved
140
+ false
141
+ end
142
+ end
143
+
129
144
  private
130
145
 
131
146
  # The toolkit's load warnings, each once per process: a name that
@@ -142,5 +157,22 @@ module Samagotchi
142
157
  @emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
143
158
  end
144
159
  end
160
+
161
+ def warn_unknown_groups(resolved, on_event)
162
+ return if resolved.groups == :all
163
+
164
+ names = @tools.call.index&.groups || []
165
+ unknown = resolved.groups.reject do |selector|
166
+ names.any? { |name| Toolkit.group_matches?([selector], name) }
167
+ end
168
+ unknown.each do |selector|
169
+ line = "#{resolved.name}: group selector #{selector.inspect} matches no group declared at load"
170
+ next if @warned.include?(line)
171
+
172
+ @warned << line
173
+ Log.warn(:config, "toolkit_group_unknown", message: line)
174
+ @emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
175
+ end
176
+ end
145
177
  end
146
178
  end
@@ -71,6 +71,15 @@ module Samagotchi
71
71
  lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
72
72
  "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
73
73
  lines << prompt_line(explained.resolved.prompt) if explained.resolved.prompt
74
+ groups = explained.resolved.groups
75
+ group_names = if groups == :all
76
+ "all"
77
+ elsif groups.empty?
78
+ "none"
79
+ else
80
+ groups.join(", ")
81
+ end
82
+ lines << " groups: #{group_names}"
74
83
  left_out = explained.left_out
75
84
  if explained.reachable.empty?
76
85
  lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
@@ -123,10 +132,24 @@ module Samagotchi
123
132
  # The kit's prompt text (Toolkit::Kit#prompt) is in the prompt too.
124
133
  text = after.resolved.prompt
125
134
  parts << (text ? "sends its kit's prompt text" : "drops the kit's prompt text") if text != before.resolved.prompt
135
+ parts.concat(group_changes(before.resolved.groups, after.resolved.groups))
126
136
  parts << "declares the same tools under its own name" if parts.empty?
127
137
  "Switching to #{after.toolkit.label} #{parts.join("; ")}."
128
138
  end
129
139
 
140
+ def group_changes(before, after)
141
+ return [] if before == after
142
+ return ["reaches all groups"] if after == :all
143
+ return ["limits groups to #{after.empty? ? "none" : after.join(", ")}"] if before == :all
144
+
145
+ added = after - before
146
+ removed = before - after
147
+ changes = []
148
+ changes << "reaches groups #{added.join(", ")}" unless added.empty?
149
+ changes << "leaves out groups #{removed.join(", ")}" unless removed.empty?
150
+ changes
151
+ end
152
+
130
153
  # What the confirm says is read again: from the tools on (the front of
131
154
  # every request), or from the kit's prompt text on when only that
132
155
  # changes (Explained#tools_key is unchanged: only a kit's prompt text).
@@ -39,7 +39,7 @@ module Samagotchi
39
39
  next if taken.include?(name) || registry.offered(name, layers: layers)
40
40
 
41
41
  taken << name
42
- deferred << Deferred.new(name: name, description: STUB_DESCRIPTION, schema: STUB_SCHEMA)
42
+ deferred << Deferred.new(name: name, description: stub_description(registry, name), schema: STUB_SCHEMA)
43
43
  end
44
44
  deferred
45
45
  end
@@ -74,7 +74,15 @@ module Samagotchi
74
74
  end
75
75
  end
76
76
 
77
- private_class_method :builtins, :sourced
77
+ def stub_description(registry, name)
78
+ index = registry.respond_to?(:index) ? registry.index : nil
79
+ tool = index&.unreached_tool(name)
80
+ return STUB_DESCRIPTION unless tool
81
+
82
+ ToolIndex::ToolkitGroupRefusal.for(tool, registry).stub_description
83
+ end
84
+
85
+ private_class_method :builtins, :sourced, :stub_description
78
86
  end
79
87
  end
80
88
  end
@@ -81,7 +81,7 @@ module Samagotchi
81
81
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
82
82
  "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
83
83
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
84
- rescue ArgumentError => e
84
+ rescue ArgumentError, Toolkit::Refused => e
85
85
  "Error: #{e.message}"
86
86
  end
87
87
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../bridge_client"
5
+ require_relative "../worker_link"
5
6
  require_relative "../relay"
6
7
  require_relative "../guardrails/approval"
7
8
  require_relative "../guardrails/parent_policy"
@@ -79,13 +80,14 @@ module Samagotchi
79
80
  qid = question.id.to_s
80
81
  desk = relay.relay_desk
81
82
  relay_id = desk.open(child_id: child_id, child_question_id: qid)
82
- client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
83
+ client = WorkerLink.client(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
83
84
  post(client, "opened", relay_id, qid)
84
85
  Log.info(:turn, "relay_opened", child: child_id[0, 8], id: qid)
85
86
 
86
87
  fields = Relay.card(child, question, relay_id: relay_id, more: more)
87
88
  answer = relay.open_question(fields, watch: watch(child_id, qid, state_dir))
88
- settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid)
89
+ settle(answer, fields, label: label(question), client: client, desk: desk, relay_id: relay_id, qid: qid,
90
+ child_id: child_id)
89
91
  ensure
90
92
  # A relay that failed before it settled closes, so the desk (which
91
93
  # prunes only settled ones) keeps none open; a settled one stays.
@@ -96,7 +98,7 @@ module Samagotchi
96
98
  # was answered or cancelled there) or its worker is gone.
97
99
  def watch(child_id, qid, state_dir)
98
100
  lambda do
99
- unless SessionManager.worker_live?(child_id, state_dir: state_dir)
101
+ unless WorkerLink.resolve(Session.session_dir(child_id, state_dir: state_dir), unlink: false).worker_owned?
100
102
  next "child_gone"
101
103
  end
102
104
 
@@ -109,7 +111,7 @@ module Samagotchi
109
111
 
110
112
  # Record the parent's answer, tell the child, and say what came of it.
111
113
  # @param label [String] the outcome line's start (#label)
112
- def settle(answer, fields, label:, client:, desk:, relay_id:, qid:)
114
+ def settle(answer, fields, label:, client:, desk:, relay_id:, qid:, child_id:)
113
115
  if answer.is_a?(Hash) && answer[:error] == "cancelled"
114
116
  desk.close(relay_id)
115
117
  return closed(answer[:reason].to_s, label, client: client, relay_id: relay_id, qid: qid)
@@ -120,7 +122,7 @@ module Samagotchi
120
122
  desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
121
123
  freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
122
124
  response = post(client, "answered", relay_id, qid)
123
- Outcome.new(line: line(label, delivered(response, answer, fields, dismissed)), stopped: false)
125
+ Outcome.new(line: line(label, delivered(response, answer, fields, dismissed, child_id)), stopped: false)
124
126
  end
125
127
 
126
128
  def closed(reason, label, client:, relay_id:, qid:)
@@ -136,12 +138,12 @@ module Samagotchi
136
138
  end
137
139
 
138
140
  # The outcome of an answer the child was told about.
139
- def delivered(response, answer, fields, dismissed)
141
+ def delivered(response, answer, fields, dismissed, child_id)
140
142
  return "not delivered (the child's worker is gone)" unless response
141
143
 
142
144
  # The child's Bridge asked for a secret this worker didn't send (one
143
145
  # from before the child's chi): not the child's guardrails refusing.
144
- return "not delivered (#{BridgeSecret::REFUSED})" if response.secret_refused?
146
+ return "not delivered (#{BridgeSecret.refused(child_id)})" if response.secret_refused?
145
147
 
146
148
  hook = fields[:kind] == HOOK_KIND
147
149
  case response.status
@@ -40,13 +40,18 @@ module Samagotchi
40
40
 
41
41
  # What Tools::ToolIndex#declare registered: the entries of +source+
42
42
  # are the index's search and call tools, +call_name+ the call tool's
43
- # name, and +sourced+ whether a source (an MCP server's bundle)
44
- # registered when the plugins loaded. +deferrable+ (→ the sources'
43
+ # name, and +groups+ the source groups registered when the plugins
44
+ # loaded. +deferrable+ (→ the sources'
45
45
  # tools a host that loads tools may declare deferred) and +wrap+
46
46
  # (call → the call_tool call a direct call of one of them runs as, or
47
47
  # nil) are nil when nothing reaches them.
48
- Index = Data.define(:source, :call_name, :sourced, :deferrable, :wrap) do
49
- def initialize(source:, call_name:, sourced:, deferrable: nil, wrap: nil) = super
48
+ Index = Data.define(:source, :call_name, :groups, :deferrable, :wrap, :unreached) do
49
+ def initialize(source:, call_name:, groups:, deferrable: nil, wrap: nil, unreached: nil)
50
+ super(source: source, call_name: call_name, groups: Array(groups).map(&:to_s).uniq.freeze,
51
+ deferrable: deferrable, wrap: wrap, unreached: unreached)
52
+ end
53
+
54
+ def unreached_tool(name) = unreached&.call(name)
50
55
  end
51
56
 
52
57
  def initialize
@@ -64,6 +69,11 @@ module Samagotchi
64
69
  # only there: a request's declared tools never change mid-turn.
65
70
  attr_accessor :toolkit
66
71
 
72
+ # Which kit reaches a source group: a lookup (group name → kit name,
73
+ # nil when none does) the Engine sets with the toolkit; a refusal's
74
+ # hint names it.
75
+ attr_accessor :reaching_kit
76
+
67
77
  # @return [Entry]
68
78
  def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil,
69
79
  unwrap: nil)
@@ -181,7 +191,7 @@ module Samagotchi
181
191
  # the declared tools stay the same for a session's life. A built-in
182
192
  # left out counts only while call_tool can reach it (not removed).
183
193
  def index_needed?(toolkit)
184
- return true if @index.sourced
194
+ return true if reached_groups?(toolkit)
185
195
  return false if toolkit&.removed&.include?(@index.call_name)
186
196
 
187
197
  index_reaches?(toolkit)
@@ -191,7 +201,11 @@ module Samagotchi
191
201
  # removed or not: a source registered at load, or a built-in the
192
202
  # toolkit leaves out without removing it.
193
203
  def index_reaches?(toolkit)
194
- @index.sourced || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
204
+ reached_groups?(toolkit) || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
205
+ end
206
+
207
+ def reached_groups?(toolkit)
208
+ @index.groups.any? { |name| toolkit.nil? || toolkit.reaches_group?(name) }
195
209
  end
196
210
 
197
211
  # Not declared by +toolkit+ under +layers+. An index tool counts only
@@ -63,6 +63,37 @@ module Samagotchi
63
63
  args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
64
64
  }.freeze
65
65
 
66
+ # The user changes kits; calls and old deferred references cannot
67
+ # make a source group reachable again.
68
+ class ToolkitGroupRefusal < StandardError
69
+ RULE = "toolkit-group"
70
+
71
+ # The refusal of +tool+ under +registry+'s toolkit, naming a kit that
72
+ # reaches its group when the registry can tell (Registry#reaching_kit).
73
+ def self.for(tool, registry)
74
+ new(tool, registry.toolkit, kit: registry.respond_to?(:reaching_kit) ? registry.reaching_kit&.call(tool.group) : nil)
75
+ end
76
+
77
+ def initialize(tool, toolkit, kit: nil)
78
+ @tool = tool
79
+ @toolkit = toolkit.name
80
+ @kit = kit
81
+ super("Error: #{tool.full_name} isn't in this session's toolkit (#{@toolkit}); #{hint}.")
82
+ end
83
+
84
+ def stub_description
85
+ "Not available in this session's toolkit (#{@toolkit}); #{hint}."
86
+ end
87
+
88
+ private
89
+
90
+ def hint
91
+ return "only the user can switch (/toolkit NAME, or a kit that reaches #{@tool.group})" unless @kit
92
+
93
+ "only the user can switch (/toolkit #{@kit}, which reaches #{@tool.group})"
94
+ end
95
+ end
96
+
66
97
  # One tool in the index: +group+/+name+ is what the call tool takes,
67
98
  # +schema+ its inputSchema, +acts_as+ the name guardrail rules match
68
99
  # (mcp_<server>_<tool>; a bare name the call tool looks up too).
@@ -141,7 +172,7 @@ module Samagotchi
141
172
  # yet). +prefix+: a bare name with it is taken as an acts_as as given.
142
173
  # +label+ is the call tool's row label.
143
174
  class Source
144
- attr_reader :label, :prefix, :context, :groups, :declared_names, :declared_tokens
175
+ attr_reader :label, :prefix, :context, :groups, :declared_names, :declared_tokens, :declared_groups
145
176
 
146
177
  def initialize(label:, call:, acts_as:, wait: nil, prefix: nil, context: nil)
147
178
  raise ArgumentError, "tool_source: call must respond to #call" unless call.respond_to?(:call)
@@ -157,6 +188,7 @@ module Samagotchi
157
188
  @groups = [].freeze
158
189
  @declared_names = []
159
190
  @declared_tokens = 0
191
+ @declared_groups = [].freeze
160
192
  end
161
193
 
162
194
  def inspect = "#<ToolIndex::Source #{@label} groups=#{@groups.map(&:name).join(",")}>"
@@ -184,9 +216,10 @@ module Samagotchi
184
216
 
185
217
  # What ToolIndex#declare registered: the tools' names and their
186
218
  # definitions' estimated tokens (the mcp bundle's /mcp says them).
187
- def declared!(names, tokens)
219
+ def declared!(names, tokens, groups)
188
220
  @declared_names = names.dup.freeze
189
221
  @declared_tokens = tokens
222
+ @declared_groups = groups.dup.freeze
190
223
  end
191
224
  end
192
225
 
@@ -196,7 +229,8 @@ module Samagotchi
196
229
  @registry = nil
197
230
  # The source groups' lines in the search tool's description, as
198
231
  # #declare found them (#find_description).
199
- @described = [].freeze
232
+ @described = {}.freeze
233
+ @declared_group_names = nil
200
234
  @dropped = []
201
235
  @dropped_mutex = Mutex.new
202
236
  end
@@ -216,7 +250,7 @@ module Samagotchi
216
250
  # sources'. A source group with a reserved name, or one an earlier
217
251
  # group has, is left out of search, lookup and the description (said
218
252
  # once per name: a source publishes after load too).
219
- def groups
253
+ def all_groups
220
254
  own = registry_groups
221
255
  taken = own.map(&:name) | RESERVED_GROUPS
222
256
  kept = @sources.flat_map(&:groups).select do |group|
@@ -230,6 +264,17 @@ module Samagotchi
230
264
  own + kept
231
265
  end
232
266
 
267
+ def reached_groups
268
+ groups = all_groups
269
+ toolkit = @registry&.toolkit
270
+ return groups if toolkit.nil? || toolkit.groups == :all
271
+
272
+ declared = @declared_group_names || groups.reject { |group| own?(group) }.map(&:name)
273
+ groups.select do |group|
274
+ own?(group) || (declared.include?(group.name) && toolkit.reaches_group?(group.name))
275
+ end
276
+ end
277
+
233
278
  # Register the search and call tools in +registry+, once, after the
234
279
  # plugins load (or without them: the built-ins a toolkit leaves out
235
280
  # are reachable either way); +registry+ declares them per toolkit
@@ -238,7 +283,8 @@ module Samagotchi
238
283
  # @return [Array<String>] the names registered
239
284
  def declare(registry, failures: nil)
240
285
  @registry = registry
241
- @described = description_lines(groups.reject { |group| own?(group) }).freeze
286
+ @described = description_lines(all_groups.reject { |group| own?(group) }).freeze
287
+ @declared_group_names = @described.keys.freeze
242
288
  specs = [find_spec, call_spec]
243
289
  declared = specs.filter_map do |spec|
244
290
  if (taken = registry[spec.name])
@@ -251,8 +297,9 @@ module Samagotchi
251
297
  registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
252
298
  spec
253
299
  end
254
- registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?,
255
- deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) })
300
+ registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, groups: @declared_group_names,
301
+ deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) },
302
+ unreached: ->(name) { unreached_tool(name) })
256
303
  declared!(declared)
257
304
  declared.map(&:name)
258
305
  end
@@ -293,7 +340,7 @@ module Samagotchi
293
340
  def find_description
294
341
  "Search tools this session doesn't declare, by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
295
342
  "its name (<group>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
296
- "name.\nGroups:\n#{(description_lines(registry_groups) + @described).join("\n")}"
343
+ "name.\nGroups:\n#{(description_lines(registry_groups).values + described_groups.values).join("\n")}"
297
344
  end
298
345
 
299
346
  # ── Search ──────────────────────────────────────────────────────────
@@ -372,9 +419,10 @@ module Samagotchi
372
419
  # same list in any session and after a respawn.
373
420
  # @return [Array<Tool>]
374
421
  def deferrable
375
- tools = groups.reject { |group| own?(group) }.flat_map(&:tools)
376
- counts = tools.map(&:acts_as).tally
377
- tools.select do |tool|
422
+ all = all_groups.reject { |group| own?(group) }.flat_map(&:tools)
423
+ reached = reached_groups.reject { |group| own?(group) }.flat_map(&:tools)
424
+ counts = all.map(&:acts_as).tally
425
+ reached.select do |tool|
378
426
  tool.refusal.nil? && counts[tool.acts_as] == 1 && tool.acts_as.match?(DEFERRABLE_NAME) &&
379
427
  !@registry&.key?(tool.acts_as)
380
428
  end.sort_by(&:acts_as)
@@ -397,6 +445,22 @@ module Samagotchi
397
445
  deferrable.any? { |t| t.acts_as == tool.acts_as } ? tool.acts_as : nil
398
446
  end
399
447
 
448
+ # Finds a source tool regardless of the active toolkit, without
449
+ # waiting for a starting group. Used only to refuse a call or explain
450
+ # a deferred stub for a group the session cannot reach.
451
+ def unreached_tool(name)
452
+ name = name.to_s.strip
453
+ return nil if name.empty?
454
+
455
+ groups = all_groups.reject { |group| own?(group) }
456
+ groups.select! { |group| @declared_group_names.include?(group.name) } if @declared_group_names
457
+ tool = tool_in(name, groups)
458
+ return nil unless tool
459
+ return nil if reached_groups.any? { |group| group.name == tool.group }
460
+
461
+ tool
462
+ end
463
+
400
464
  # What a deferred source tool's wire name may be (Messages' tool
401
465
  # name rule).
402
466
  DEFERRABLE_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
@@ -466,7 +530,7 @@ module Samagotchi
466
530
  end
467
531
 
468
532
  def find_in(query, only)
469
- all = groups
533
+ all = reached_groups
470
534
  searched = all
471
535
  if only && !only.strip.empty?
472
536
  searched = all.select { |group| group.name == only.strip }
@@ -502,7 +566,14 @@ module Samagotchi
502
566
  def declared!(specs)
503
567
  names = specs.map(&:name)
504
568
  tokens = specs.sum { |spec| ToolIndex.spec_tokens(spec) }
505
- @sources.each { |source| source.declared!(names, tokens) }
569
+ groups = reached_groups.reject { |group| own?(group) }.map(&:name)
570
+ @sources.each { |source| source.declared!(names, tokens, groups) }
571
+ end
572
+
573
+ def described_groups
574
+ return @described unless @registry&.toolkit && @registry.toolkit.groups != :all
575
+
576
+ @described.select { |name, _line| @registry.toolkit.reaches_group?(name) }
506
577
  end
507
578
 
508
579
  def own?(group) = RESERVED_GROUPS.include?(group.name)
@@ -536,16 +607,16 @@ module Samagotchi
536
607
  # One line per group in the search tool's description: its name, its
537
608
  # summary and how many tools it can call.
538
609
  def description_lines(groups)
539
- groups.map do |group|
610
+ groups.to_h do |group|
540
611
  count = group.callable.size
541
612
  line = "- #{group.name}"
542
613
  line += ": #{group.summary}" if group.summary
543
614
  line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
544
- line
615
+ [group.name, line]
545
616
  end
546
617
  end
547
618
 
548
- # Say once per name that a source's group was left out (#groups).
619
+ # Say once per name that a source's group was left out (#all_groups).
549
620
  def dropped(name, why)
550
621
  first = @dropped_mutex.synchronize { @dropped.include?(name) ? false : (@dropped << name) }
551
622
  return unless first
@@ -614,7 +685,7 @@ module Samagotchi
614
685
  # @return [Tool, String]
615
686
  def lookup(name, waited: false)
616
687
  name = name.strip
617
- all = groups
688
+ all = reached_groups
618
689
  tool = all.lazy.filter_map do |group|
619
690
  next unless name.start_with?("#{group.name}/")
620
691
 
@@ -632,8 +703,8 @@ module Samagotchi
632
703
 
633
704
  # A name with a group's <group>/ waits for that group only; any
634
705
  # other name (an acts_as) for every one starting.
635
- named = all.select { |group| name.start_with?("#{group.name}/") }
636
- starting = (named.empty? ? all : named).select { |group| group.state == :starting }
706
+ named = reached_groups.select { |group| name.start_with?("#{group.name}/") }
707
+ starting = (named.empty? ? reached_groups : named).select { |group| group.state == :starting }
637
708
  unless waited || starting.empty?
638
709
  starting.each { |group| source_of(group.name)&.wait(group.name) }
639
710
  return lookup(name, waited: true)
@@ -654,6 +725,18 @@ module Samagotchi
654
725
  tool.is_a?(Tool) ? tool : nil
655
726
  end
656
727
 
728
+ def tool_in(name, groups)
729
+ named = groups.filter_map do |group|
730
+ next unless name.start_with?("#{group.name}/")
731
+
732
+ group.tools.find { |tool| tool.name == name.delete_prefix("#{group.name}/") }
733
+ end
734
+ return named.first if named.one?
735
+
736
+ same = groups.flat_map(&:tools).select { |tool| tool.acts_as == name }
737
+ same.first if same.one?
738
+ end
739
+
657
740
  # The acts_as and label +name+ stands for, from the name alone:
658
741
  # <group>/<tool> of a known group (the one that has the tool, else the
659
742
  # first whose name it starts with), its source naming the acts_as; a
@@ -662,7 +745,7 @@ module Samagotchi
662
745
  # anything else.
663
746
  # @return [Array(String, String), Array(String, nil), nil]
664
747
  def claimed(name)
665
- all = groups
748
+ all = all_groups
666
749
  if (own = all.select { |g| own?(g) }.find { |g| g.tools.any? { |t| t.acts_as == name } })
667
750
  return [name, "#{own.name}: #{name}"]
668
751
  end
@@ -93,8 +93,9 @@ module Samagotchi
93
93
  SessionInbox.prune_old(session_dir, SETTLED_DIR, max_age: max_age)
94
94
  end
95
95
 
96
- def self.settled_record(id, ended_at: nil, seconds: nil)
97
- LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
96
+ def self.settled_record(id, ended_at: nil, seconds: nil, author: nil)
97
+ LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id,
98
+ author: author)
98
99
  end
99
100
  private_class_method :settled_record
100
101
 
@@ -123,13 +124,15 @@ module Samagotchi
123
124
 
124
125
  # A participant's queued turn the worker refused (Worker#refuse_participant_turn:
125
126
  # +reason+ revoked or cap_reached) ends its sender's wait: no turn ran,
126
- # canceled for that reason (ParentReport reads it as refused).
127
- def self.record_refused(session_dir, enqueued_id, reason:)
127
+ # canceled for that reason (ParentReport reads it as refused). Its
128
+ # +author+ (the participant's "p:<id>") says whose wait it is to a chi
129
+ # web restarted since (ParticipantAccess#turn_author).
130
+ def self.record_refused(session_dir, enqueued_id, reason:, author: nil)
128
131
  id = enqueued_id.to_s
129
132
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
130
133
 
131
134
  write_settled(session_dir, id, LastTurn.new(outcome: "canceled", cancel_reason: reason.to_s,
132
- ended_at: Time.now.iso8601(3), enqueued_id: id))
135
+ ended_at: Time.now.iso8601(3), enqueued_id: id, author: author))
133
136
  rescue SystemCallError, IOError => e
134
137
  Log.warn(:worker, "refused_turn_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
135
138
  end
@@ -138,7 +141,7 @@ module Samagotchi
138
141
  id = mark.enqueued_id.to_s
139
142
  return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
140
143
 
141
- write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds))
144
+ write_settled(session_dir, id, settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds, author: mark.author))
142
145
  rescue SystemCallError => e
143
146
  # The settle goes on; a wait for that turn runs to its timeout.
144
147
  Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
@@ -163,6 +166,7 @@ module Samagotchi
163
166
  @written_at = nil
164
167
  @started_at = nil
165
168
  @enqueued_id = nil
169
+ @author = nil
166
170
  end
167
171
 
168
172
  # A turn begins (before Engine#run_turn and the worker's running save):
@@ -170,7 +174,8 @@ module Samagotchi
170
174
  # images, no turn id), marked at iteration 0, so a worker killed before
171
175
  # the loop's first boundary leaves the prompt and a mark the next one
172
176
  # settles. A continue turn (no prompt) is the history, marked.
173
- # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
177
+ # Every mark of the turn carries +enqueued_id+ and a participant's
178
+ # +author+ (InterruptedTurn's).
174
179
  # @param prompt [String, nil]
175
180
  # @param enqueued_id [String, nil] the input the turn runs
176
181
  # @param author [Hash] a participant's prompt's author fields
@@ -179,6 +184,7 @@ module Samagotchi
179
184
  def begin_turn(prompt: nil, enqueued_id: nil, author: {})
180
185
  @boundaries = 0
181
186
  @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
187
+ @author = author[:author]
182
188
  @written_at = nil
183
189
  @started_at = Time.now.iso8601(3)
184
190
  messages = Array(@session.messages).dup
@@ -186,7 +192,7 @@ module Samagotchi
186
192
  # stamp (Engine's Turn#wall), which the turn's first boundary saves.
187
193
  messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
188
194
  @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
189
- enqueued_id: @enqueued_id) }
195
+ enqueued_id: @enqueued_id, author: @author) }
190
196
  end
191
197
 
192
198
  # The turn ended (or never began): the session saves its own messages.
@@ -212,7 +218,7 @@ module Samagotchi
212
218
  # forget stub) while a save on another thread serializes this one.
213
219
  messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
214
220
  marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
215
- enqueued_id: @enqueued_id)
221
+ enqueued_id: @enqueued_id, author: @author)
216
222
  @session.turn_checkpoint = { messages: messages, marker: marker }
217
223
  return false unless due? || !merged.empty?
218
224
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.120.0"
4
+ VERSION = "0.121.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end