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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +34 -11
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +6 -2
- data/docs/internals/prompt-caching.md +7 -6
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +28 -8
- data/docs/sessions.md +15 -0
- data/docs/testing.md +44 -2
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/engine.rb +1 -0
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/kernel_loop.rb +5 -0
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/toolkit.rb +117 -27
- data/lib/samagotchi/toolkit_binding.rb +32 -0
- data/lib/samagotchi/toolkit_command.rb +23 -0
- data/lib/samagotchi/tools/deferred_tools.rb +10 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/registry.rb +20 -6
- data/lib/samagotchi/tools/tool_index.rb +103 -20
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +11 -6
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/forms.css +12 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/level_chip.js +2 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +4 -2
- data/lib/samagotchi/web/public/participant.js +34 -11
- data/lib/samagotchi/web/public/participants_panel.js +49 -14
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +15 -1
- data/lib/samagotchi/web/public/question_cards.js +11 -2
- data/lib/samagotchi/web/public/review_history.js +24 -4
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/tunnel.rb +10 -1
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- 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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 +
|
|
44
|
-
#
|
|
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, :
|
|
49
|
-
def initialize(source:, call_name:,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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,
|
|
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) +
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 (#
|
|
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 =
|
|
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 =
|
|
636
|
-
starting = (named.empty? ?
|
|
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 =
|
|
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
|
-
|
|
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+
|
|
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
|
|