samagotchi 0.96.0 → 0.98.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +12 -2
- data/docs/bundle-authoring.md +7 -4
- data/docs/cli.md +13 -4
- data/docs/configuration.md +13 -0
- data/docs/hooks.md +59 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -2
- data/docs/internals/engine.md +21 -1
- data/docs/memory.md +43 -3
- data/docs/plugins.md +8 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/answer_refs.rb +16 -9
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +75 -40
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/llm/errors.rb +135 -3
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +12 -3
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/send_command.rb +11 -11
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +1 -0
- data/lib/samagotchi/testing/bundle.rb +18 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -9
- data/lib/samagotchi/web/public/app.js +44 -11
- data/lib/samagotchi/web/public/index.html +36 -13
- data/lib/samagotchi/web/public/info_bar_model.js +0 -2
- data/lib/samagotchi/web/public/lists_view.js +32 -8
- data/lib/samagotchi/web/public/markdown_place.js +25 -8
- data/lib/samagotchi/web/public/question_cards.js +23 -6
- data/lib/samagotchi/web/public/session_rename.js +88 -15
- data/lib/samagotchi/web/public/session_title.js +6 -0
- data/lib/samagotchi/web/public/stage_frame.js +24 -14
- data/lib/samagotchi/web/public/stage_model.js +4 -2
- data/lib/samagotchi/web/public/stage_slots.js +41 -9
- data/lib/samagotchi/web/public/stage_view.js +149 -8
- data/lib/samagotchi/web/public/stream_handlers.js +5 -1
- data/lib/samagotchi/web/public/turn_model.js +20 -0
- data/lib/samagotchi/web/public/turn_view.js +22 -9
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +3 -1
|
@@ -51,8 +51,9 @@ module Samagotchi
|
|
|
51
51
|
|
|
52
52
|
LIB_DIR = File.expand_path("../..", __dir__)
|
|
53
53
|
TRUSTED = %w[reviewed trusted].freeze
|
|
54
|
-
# A call of the plugin API
|
|
55
|
-
|
|
54
|
+
# A call of the plugin API (with one member after it: ctx.sessions.children),
|
|
55
|
+
# or a name SINCE lists, in the source.
|
|
56
|
+
API_CALL = /\b(?:chi|ctx)\.[a-z_]+(?:\.[a-z_]+)?\b|\bfallback_for:|\bSamagotchi::AtomicFile\b/
|
|
56
57
|
# A requirement's lower bound: >= X, > X, ~> X or = X.
|
|
57
58
|
LOWER_BOUND = /\A(?:>=|>|~>|=)?\s*(\d+(?:\.\d+)*)\z/
|
|
58
59
|
|
|
@@ -213,7 +214,8 @@ module Samagotchi
|
|
|
213
214
|
floor = lower_bound(manifest.requires_chi)
|
|
214
215
|
return unless floor && File.file?(path(manifest.plugin[:file]))
|
|
215
216
|
|
|
216
|
-
calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).
|
|
217
|
+
calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
|
|
218
|
+
.uniq.select { |call| Plugin::Api::SINCE.key?(call) }
|
|
217
219
|
call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
|
|
218
220
|
version = Plugin::Api::SINCE[call]
|
|
219
221
|
return unless Gem::Version.new(version) > floor
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "rbconfig"
|
|
5
|
+
require_relative "../../cli/exit"
|
|
5
6
|
require_relative "../../process_group"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
@@ -54,7 +55,7 @@ module Samagotchi
|
|
|
54
55
|
end
|
|
55
56
|
trap_state[:pid] = nil
|
|
56
57
|
out, err = finish_readers(readers, streams)
|
|
57
|
-
code = timed_out ? CLI::Exit::FAILED :
|
|
58
|
+
code = timed_out ? CLI::Exit::FAILED : exit_code(status, trap_state)
|
|
58
59
|
Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
|
|
59
60
|
timed_out: timed_out)
|
|
60
61
|
end
|
|
@@ -91,13 +92,16 @@ module Samagotchi
|
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
def trap_forwarding
|
|
94
|
-
state = { pid: nil, wait: nil, pending: nil }
|
|
95
|
+
state = { pid: nil, wait: nil, pending: nil, interrupted: false }
|
|
95
96
|
old = {}
|
|
96
97
|
%w[INT TERM].each do |signal|
|
|
97
98
|
old[signal] = Signal.trap(signal) do
|
|
98
99
|
if state[:pid]
|
|
99
100
|
# Not once its waiter reaped it: that group may be gone.
|
|
100
|
-
|
|
101
|
+
if state[:wait]&.alive?
|
|
102
|
+
state[:interrupted] ||= signal == "INT"
|
|
103
|
+
ProcessGroup.signal(state[:pid], signal)
|
|
104
|
+
end
|
|
101
105
|
else
|
|
102
106
|
state[:pending] ||= signal
|
|
103
107
|
end
|
|
@@ -111,7 +115,10 @@ module Samagotchi
|
|
|
111
115
|
state[:pid] = pid
|
|
112
116
|
pending = state[:pending]
|
|
113
117
|
state[:pending] = nil
|
|
114
|
-
|
|
118
|
+
return unless pending
|
|
119
|
+
|
|
120
|
+
state[:interrupted] ||= pending == "INT"
|
|
121
|
+
ProcessGroup.signal(pid, pending)
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
def restore_traps(old)
|
|
@@ -171,6 +178,14 @@ module Samagotchi
|
|
|
171
178
|
readers.each(&:join)
|
|
172
179
|
end
|
|
173
180
|
|
|
181
|
+
# A Ctrl-C forwarded to the child is an interrupted run, however it
|
|
182
|
+
# ended: a Ruby still booting (before chi's INT handler) exits 1.
|
|
183
|
+
def exit_code(status, trap_state)
|
|
184
|
+
return CLI::Exit::INTERRUPTED if trap_state[:interrupted] && !status.success?
|
|
185
|
+
|
|
186
|
+
process_exit_status(status)
|
|
187
|
+
end
|
|
188
|
+
|
|
174
189
|
def process_exit_status(status)
|
|
175
190
|
status.exitstatus || (128 + status.termsig.to_i)
|
|
176
191
|
end
|
|
@@ -476,7 +476,7 @@ module Samagotchi
|
|
|
476
476
|
hooks.each do |hk, hook|
|
|
477
477
|
hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk)
|
|
478
478
|
exists = File.exist?(hook_path) ? "ok" : "missing"
|
|
479
|
-
@stdout.puts " #{hk}:
|
|
479
|
+
@stdout.puts " #{hk}: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
|
|
480
480
|
end
|
|
481
481
|
@stdout.puts " requires_chi: #{st[:provenance].requires_chi}" if st[:hooks_requires_failure]
|
|
482
482
|
@stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
|
|
@@ -658,7 +658,7 @@ module Samagotchi
|
|
|
658
658
|
end
|
|
659
659
|
|
|
660
660
|
def hook_metadata_line(hook)
|
|
661
|
-
"--- metadata:
|
|
661
|
+
"--- metadata: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} sha256=#{hook.sha256}"
|
|
662
662
|
end
|
|
663
663
|
|
|
664
664
|
def build(rest)
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
require "open3"
|
|
5
5
|
require "samagotchi/ref"
|
|
6
6
|
|
|
7
|
-
# An after_turn hook that links the source refs the
|
|
8
|
-
# mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
|
|
7
|
+
# An after_turn (and question_asked) hook that links the source refs the
|
|
8
|
+
# model's answer mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
|
|
9
9
|
# web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
|
|
10
10
|
# through event[:present]: display only, the model's text stays as it was,
|
|
11
11
|
# and it survives a reload). Every UI also gets one line right after the
|
|
@@ -58,6 +58,14 @@ require "samagotchi/ref"
|
|
|
58
58
|
# action, "Open in <name>", the ref's URL. A URL that isn't http(s) stays a
|
|
59
59
|
# markdown link only. The web's ref menu shows the action.
|
|
60
60
|
#
|
|
61
|
+
# The hook is on :question_asked too (the manifest's events:, chi newer than
|
|
62
|
+
# 0.97.0): while a question of the model's (or an approval of its call)
|
|
63
|
+
# waits, the refs in the reply that asks it, and in the question's text and
|
|
64
|
+
# options, are refs on that reply, the same as an answer's. The web pairs
|
|
65
|
+
# them in the stage's asking slot, on the question's card and on that step
|
|
66
|
+
# in the history (a ref only in the question's text pairs there alone). No
|
|
67
|
+
# links in the text and no note line for it: the turn's end has those.
|
|
68
|
+
#
|
|
61
69
|
# The note is not stored in the conversation: it is an event, replayed by a
|
|
62
70
|
# UI with the session's cards (a reload keeps it, after the worker stopped
|
|
63
71
|
# too: cards.json). The links are stored as the answer's display. A ref in
|
|
@@ -120,10 +128,20 @@ class SourceLinks
|
|
|
120
128
|
end
|
|
121
129
|
|
|
122
130
|
def call(event)
|
|
123
|
-
return unless event.is_a?(Hash)
|
|
124
|
-
return unless event[:status].to_s == "completed"
|
|
131
|
+
return unless event.is_a?(Hash)
|
|
125
132
|
return if @sources.empty?
|
|
126
133
|
|
|
134
|
+
case event[:type]
|
|
135
|
+
when :after_turn then after_turn(event)
|
|
136
|
+
when :question_asked then question_asked(event)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def after_turn(event)
|
|
143
|
+
return unless event[:status].to_s == "completed"
|
|
144
|
+
|
|
127
145
|
text = last_model_text(event[:messages])
|
|
128
146
|
return if text.nil? || text.empty?
|
|
129
147
|
|
|
@@ -141,7 +159,23 @@ class SourceLinks
|
|
|
141
159
|
event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
|
|
142
160
|
end
|
|
143
161
|
|
|
144
|
-
|
|
162
|
+
# A question of the model's (or an approval of its call) is about to
|
|
163
|
+
# open: the refs in the reply that asks it and in the question's own text
|
|
164
|
+
# and options, each scanned on its own (a code span never runs from one
|
|
165
|
+
# into the next), as refs on that reply. No present (a mid-turn text has
|
|
166
|
+
# no display) and no note line (the after-turn one stays the terminals').
|
|
167
|
+
def question_asked(event)
|
|
168
|
+
refs = event[:refs]
|
|
169
|
+
return unless refs
|
|
170
|
+
|
|
171
|
+
question = event[:question].is_a?(Hash) ? event[:question] : {}
|
|
172
|
+
field = ->(key) { question.key?(key) ? question[key] : question[key.to_s] }
|
|
173
|
+
parts = [event[:text], field.call(:question), *Array(field.call(:options))]
|
|
174
|
+
parts.each do |part|
|
|
175
|
+
text = part.is_a?(String) ? part : ""
|
|
176
|
+
add_refs(refs, occurrences(text[0, MAX_SCAN])) unless text.empty?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
145
179
|
|
|
146
180
|
# Links the refs in the answer as shown: the model's text unless an
|
|
147
181
|
# earlier hook changed it (then its offsets differ, so it is scanned
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.6.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
5
|
+
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
requires_chi: ">= 0.
|
|
7
|
+
requires_chi: ">= 0.98.0"
|
|
8
8
|
files:
|
|
9
9
|
source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
|
|
10
10
|
hooks:
|
|
11
11
|
source_links.rb:
|
|
12
|
-
sha256: sha256:
|
|
13
|
-
|
|
12
|
+
sha256: sha256:84c0bef064fb1ea76749a61e758eba612662aaf0f05f18a18ab4228497078fa2
|
|
13
|
+
events: [after_turn, question_asked]
|
|
14
14
|
on_error: log
|
|
15
15
|
priority: 90
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -77,6 +77,7 @@ require_relative "answer_display"
|
|
|
77
77
|
require_relative "answer_refs"
|
|
78
78
|
require_relative "edit_preview"
|
|
79
79
|
require_relative "question_desk"
|
|
80
|
+
require_relative "question_asked"
|
|
80
81
|
require_relative "child_reports"
|
|
81
82
|
require_relative "relay_desk"
|
|
82
83
|
require_relative "guardrail_wiring"
|
|
@@ -187,49 +188,13 @@ module Samagotchi
|
|
|
187
188
|
@question_desk = build_question_desk
|
|
188
189
|
# Built here, not on first use: the turn thread and the Bridge's read it.
|
|
189
190
|
@relay_desk = RelayDesk.new
|
|
190
|
-
@extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
|
|
191
|
-
@hooks = @extension_load.hooks
|
|
192
|
-
# The tools this session offers (the prompts' declarations and the
|
|
193
|
-
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
194
|
-
# off: execute offers no description parameter. +task_finish_notes+:
|
|
195
|
-
# a worker's, whose finish wake reports a task left running (the
|
|
196
|
-
# Worker's TaskWatch): execute and task_create say so.
|
|
197
|
-
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
|
|
198
|
-
policy: Config.get(LLMContextStrategy::POLICY_SETTING),
|
|
199
|
-
task_finish_notes: task_finish_notes)
|
|
200
|
-
restrict_scratch_tools! if @scratch
|
|
201
|
-
# Likewise the slash commands its SessionCommands run.
|
|
202
|
-
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
203
|
-
# Installed bundles' plugins add commands, tools and hooks to these
|
|
204
|
-
# (docs/plugins.md); one that fails is announced with the load
|
|
205
|
-
# failures, and the rest still load.
|
|
206
|
-
# Their services (chi.service), which #shutdown stops, and the anytime
|
|
207
|
-
# commands running now (#spawn_anytime), which it waits for.
|
|
208
|
-
@services = Plugin::Services.new
|
|
209
|
-
@anytime_threads = []
|
|
210
|
-
# chi.init tasks (#add_init_task), started by #start_init_tasks!, and
|
|
211
|
-
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
212
|
-
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
213
|
-
@plugin_tasks = build_plugin_tasks
|
|
214
|
-
# The session's toolkit: the kits (the plugins' too, chi.toolkit),
|
|
215
|
-
# what its next turn declares, the turn-start apply.
|
|
216
|
-
@toolkit_binding = build_toolkit_binding
|
|
217
191
|
# The notices a turn's thinking level leads to (once per session and host).
|
|
218
192
|
@thinking_notices = ThinkingNotices.new(session: -> { @session }, profile: -> { self.profile },
|
|
219
193
|
notify: ->(text, level, source) { hook_notify(text, level, source) })
|
|
220
|
-
# The tools plugins publish without declaring (chi.tool_source), and
|
|
221
|
-
# the search and call tools that reach them (Tools::ToolIndex).
|
|
222
|
-
@tool_index = Tools::ToolIndex.new
|
|
223
|
-
# The context sources the peer bundles declare as a peer agent's
|
|
224
|
-
# answers (chi.peer_source); the worker's ContextAbsorber asks it.
|
|
225
|
-
@peer_sources = PeerSources.new
|
|
226
194
|
@lifecycle_mutex = Mutex.new
|
|
227
195
|
@shut_down = false
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
231
|
-
# either way (declared per toolkit, Tools::Registry).
|
|
232
|
-
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
196
|
+
# The built-ins, the commands, and the plugins that add to them.
|
|
197
|
+
build_tools_and_plugins(load: plugins, task_finish_notes: task_finish_notes)
|
|
233
198
|
guardrail_rules
|
|
234
199
|
# The Engine owns the reminder store; its kernel (built here, or a
|
|
235
200
|
# spec's) gets it, so tool calls via KernelLoop and reminder injection
|
|
@@ -277,6 +242,52 @@ module Samagotchi
|
|
|
277
242
|
|
|
278
243
|
# ── initialize's collaborators ──────────────────────────────────────────
|
|
279
244
|
|
|
245
|
+
# The Engine as the plugins' host: the tools and slash commands this
|
|
246
|
+
# session offers, what plugins add to them (services, init tasks, the
|
|
247
|
+
# toolkit, the tool index, peer sources), then the plugins themselves
|
|
248
|
+
# unless +load+ is false (see #initialize for when).
|
|
249
|
+
def build_tools_and_plugins(load:, task_finish_notes:)
|
|
250
|
+
@extension_load = ExtensionLoad.new(hook_failures: @guardrail_failures)
|
|
251
|
+
@hooks = @extension_load.hooks
|
|
252
|
+
# The tools this session offers (the prompts' declarations and the
|
|
253
|
+
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
254
|
+
# off: execute offers no description parameter. +task_finish_notes+:
|
|
255
|
+
# a worker's, whose finish wake reports a task left running (the
|
|
256
|
+
# Worker's TaskWatch): execute and task_create say so.
|
|
257
|
+
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false,
|
|
258
|
+
policy: Config.get(LLMContextStrategy::POLICY_SETTING),
|
|
259
|
+
task_finish_notes: task_finish_notes)
|
|
260
|
+
restrict_scratch_tools! if @scratch
|
|
261
|
+
# Likewise the slash commands its SessionCommands run.
|
|
262
|
+
@command_registry = SessionCommands.register_builtins(Commands::Registry.new)
|
|
263
|
+
# Installed bundles' plugins add commands, tools and hooks to these
|
|
264
|
+
# (docs/plugins.md); one that fails is announced with the load
|
|
265
|
+
# failures, and the rest still load.
|
|
266
|
+
# Their services (chi.service), which #shutdown stops, and the anytime
|
|
267
|
+
# commands running now (#spawn_anytime), which it waits for.
|
|
268
|
+
@services = Plugin::Services.new
|
|
269
|
+
@anytime_threads = []
|
|
270
|
+
# chi.init tasks (#add_init_task), started by #start_init_tasks!, and
|
|
271
|
+
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
272
|
+
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
273
|
+
@plugin_tasks = build_plugin_tasks
|
|
274
|
+
# The session's toolkit: the kits (the plugins' too, chi.toolkit),
|
|
275
|
+
# what its next turn declares, the turn-start apply.
|
|
276
|
+
@toolkit_binding = build_toolkit_binding
|
|
277
|
+
# The tools plugins publish without declaring (chi.tool_source), and
|
|
278
|
+
# the search and call tools that reach them (Tools::ToolIndex).
|
|
279
|
+
@tool_index = Tools::ToolIndex.new
|
|
280
|
+
# The context sources the peer bundles declare as a peer agent's
|
|
281
|
+
# answers (chi.peer_source); the worker's ContextAbsorber asks it.
|
|
282
|
+
@peer_sources = PeerSources.new
|
|
283
|
+
load_plugins if load
|
|
284
|
+
# Registered from what the sources published while they loaded, and
|
|
285
|
+
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
286
|
+
# either way (declared per toolkit, Tools::Registry).
|
|
287
|
+
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
288
|
+
end
|
|
289
|
+
private :build_tools_and_plugins
|
|
290
|
+
|
|
280
291
|
# The tools a scratch session doesn't offer, and its memory_write.
|
|
281
292
|
def restrict_scratch_tools!
|
|
282
293
|
# A scratch session's children would outlive it, and so would a
|
|
@@ -354,7 +365,8 @@ module Samagotchi
|
|
|
354
365
|
model_key: -> { @model_key },
|
|
355
366
|
model_name: -> { guardrail_model_name },
|
|
356
367
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
357
|
-
|
|
368
|
+
# An approval of the model's call: :question_asked first (QuestionAsked).
|
|
369
|
+
ask: ->(fields) { open_question(question_asked(QuestionAsked::KIND_APPROVAL, fields)) },
|
|
358
370
|
notify: ->(message) { guardrail_notify(message) }
|
|
359
371
|
)
|
|
360
372
|
end
|
|
@@ -1260,6 +1272,7 @@ module Samagotchi
|
|
|
1260
1272
|
muted_memory_names: @muted_memory_names.dup,
|
|
1261
1273
|
prompt_notes: prompt_notes.map(&:to_h),
|
|
1262
1274
|
parent_id: @session&.parent_id,
|
|
1275
|
+
title: @session&.title,
|
|
1263
1276
|
model_name: effective_model_ref,
|
|
1264
1277
|
served_model: served_pair[0],
|
|
1265
1278
|
served_model_for: served_pair[1],
|
|
@@ -1565,13 +1578,35 @@ module Samagotchi
|
|
|
1565
1578
|
|
|
1566
1579
|
# Ask the model's question (ask_user_question): the kernel's
|
|
1567
1580
|
# question_handler, called on the turn thread with the payload
|
|
1568
|
-
# Tools::AskUserQuestion.validate made (QuestionDesk#request)
|
|
1581
|
+
# Tools::AskUserQuestion.validate made (QuestionDesk#request), after
|
|
1582
|
+
# the :question_asked hooks (QuestionAsked) gave the step its refs; a
|
|
1583
|
+
# --non-interactive run has no one to ask, so they don't fire there.
|
|
1569
1584
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
1570
1585
|
# @return [String] normalized answer JSON
|
|
1571
1586
|
def request_question(payload)
|
|
1587
|
+
payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
|
|
1572
1588
|
@question_desk.request(payload)
|
|
1573
1589
|
end
|
|
1574
1590
|
|
|
1591
|
+
# The :question_asked hooks for a question of the model's about to
|
|
1592
|
+
# open: the fields with the asking step's refs (QuestionAsked.fire).
|
|
1593
|
+
# @return [Hash]
|
|
1594
|
+
def question_asked(kind, fields)
|
|
1595
|
+
QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
|
|
1596
|
+
cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
|
|
1597
|
+
visible: ->(content) { question_step_text(content) })
|
|
1598
|
+
end
|
|
1599
|
+
private :question_asked
|
|
1600
|
+
|
|
1601
|
+
# A step's text as the asking slot shows it: the native loop's raw
|
|
1602
|
+
# response without its thoughts and tool-call bodies (a chat step has
|
|
1603
|
+
# neither), then the markup's tokens (OutputFormatter.strip_markup).
|
|
1604
|
+
def question_step_text(content)
|
|
1605
|
+
parser = @kernel.parser
|
|
1606
|
+
OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
|
|
1607
|
+
end
|
|
1608
|
+
private :question_step_text
|
|
1609
|
+
|
|
1575
1610
|
# Open a question for the UIs and wait for its answer
|
|
1576
1611
|
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
1577
1612
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
@@ -69,9 +69,9 @@ module Samagotchi
|
|
|
69
69
|
|
|
70
70
|
hook = MemoryBundle::BundleHook.parse(raw_meta)
|
|
71
71
|
# A hook with no declared event cannot be auto-registered.
|
|
72
|
-
next if hook.
|
|
72
|
+
next if hook.events.empty?
|
|
73
73
|
|
|
74
|
-
fail_closed = hook.
|
|
74
|
+
fail_closed = hook.events.any? { |event| hook.fail_closed?(event) }
|
|
75
75
|
what = "hook #{basename} (bundle #{bundle_name})"
|
|
76
76
|
file = hooks_dir && File.join(hooks_dir, basename)
|
|
77
77
|
unless file && File.exist?(file)
|
|
@@ -98,9 +98,10 @@ module Samagotchi
|
|
|
98
98
|
loaded
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
# Build one hook file and register it in +registry+ under
|
|
102
|
-
#
|
|
103
|
-
#
|
|
101
|
+
# Build one hook file and register it in +registry+ under each of
|
|
102
|
+
# its events (one instance for all: its state is shared), with its
|
|
103
|
+
# priority and error policy (a fail_closed before_tool_call one
|
|
104
|
+
# denies when it raises). No missing-file, sha or requires_chi
|
|
104
105
|
# check: #load makes them; the test kit loads a bundle's source
|
|
105
106
|
# this way.
|
|
106
107
|
# @param hook [MemoryBundle::BundleHook] its manifest entry
|
|
@@ -109,17 +110,19 @@ module Samagotchi
|
|
|
109
110
|
# @raise [Exception] what loading or building the file raised;
|
|
110
111
|
# nothing is registered then
|
|
111
112
|
def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil)
|
|
112
|
-
event_sym = hook.event.to_sym
|
|
113
|
-
fail_closed = hook.on_error == "fail_closed" && event_sym == :before_tool_call
|
|
114
113
|
plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
114
|
+
hook.events.each do |event_name|
|
|
115
|
+
event_sym = event_name.to_sym
|
|
116
|
+
fail_closed = hook.fail_closed?(event_sym)
|
|
117
|
+
policy = if fail_closed then :deny
|
|
118
|
+
elsif hook.on_error == "log" then :log
|
|
119
|
+
else :skip
|
|
120
|
+
end
|
|
121
|
+
label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
|
|
122
|
+
handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
|
|
123
|
+
fields: { bundle: bundle_name }) { |event| plugin.call(event) }
|
|
124
|
+
registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
|
|
125
|
+
end
|
|
123
126
|
nil
|
|
124
127
|
end
|
|
125
128
|
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -24,12 +24,15 @@ module Samagotchi
|
|
|
24
24
|
# :before_tool_call — before a tool is dispatched: vote with event[:guardrail].deny!/ask!
|
|
25
25
|
# (or the older event[:blocked]=true + event[:block_reason]); the
|
|
26
26
|
# event also has context: and targets: (docs/guardrails.md)
|
|
27
|
+
# :question_asked — a question of the model's (ask_user_question) or an approval of its
|
|
28
|
+
# call is about to open: event[:refs] attaches refs to the asking
|
|
29
|
+
# reply (QuestionAsked)
|
|
27
30
|
# :after_tool_call — after tool execution, before result injection
|
|
28
31
|
module Hooks
|
|
29
32
|
# Every event a hook can be registered for, in a turn's order
|
|
30
33
|
# (docs/hooks.md). Plugin::Api#on takes any symbol: `chi bundle check`
|
|
31
34
|
# warns on one not here.
|
|
32
35
|
EVENTS = %i[session_start before_turn before_generation generation_progress after_generation before_tool_call
|
|
33
|
-
after_tool_call after_turn session_end].freeze
|
|
36
|
+
question_asked after_tool_call after_turn session_end].freeze
|
|
34
37
|
end
|
|
35
38
|
end
|
|
@@ -191,12 +191,24 @@ module Samagotchi
|
|
|
191
191
|
elsif tools_unsupported?
|
|
192
192
|
"host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
|
|
193
193
|
else
|
|
194
|
-
"host #{host} rejected the request: #{
|
|
194
|
+
"host #{host} rejected the request: #{hinted_detail}"
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
private
|
|
199
199
|
|
|
200
|
+
# The detail with the hint, ahead of a model list the detail has (a
|
|
201
|
+
# 404's "Available models: …", cut at the message's limit), else at
|
|
202
|
+
# its end.
|
|
203
|
+
def hinted_detail
|
|
204
|
+
return detail unless hint
|
|
205
|
+
|
|
206
|
+
list_at = detail.index(ProviderErrors::AVAILABLE_MODELS_START_RE)
|
|
207
|
+
return "#{detail}; #{hint}" unless list_at
|
|
208
|
+
|
|
209
|
+
"#{detail[0...list_at].sub(/[\s.;:]+\z/, "")}; #{hint} #{detail[list_at..].lstrip}"
|
|
210
|
+
end
|
|
211
|
+
|
|
200
212
|
# The detail up to the end of the sentence that says the model takes no
|
|
201
213
|
# tools: what a provider adds after it (OpenRouter: "Try disabling
|
|
202
214
|
# "execute"…" and a routing docs link) is advice for its own UI.
|
|
@@ -384,6 +396,28 @@ module Samagotchi
|
|
|
384
396
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
385
397
|
CREDITS_SETTLE_DELAY = 20.0
|
|
386
398
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
399
|
+
# How much of a server's error message the summary keeps.
|
|
400
|
+
MESSAGE_LIMIT = 500
|
|
401
|
+
# The 404 model hint's bounds (the message is the server's): how much
|
|
402
|
+
# of the message it reads, the longest model id, how many listed ids.
|
|
403
|
+
HINT_SCAN_CHARS = 64 * 1024
|
|
404
|
+
HINT_ID_MAX = 200
|
|
405
|
+
HINT_IDS_MAX = 2000
|
|
406
|
+
# A typo the hint forgives: at most this many edits, fewer than a
|
|
407
|
+
# third of the name, in a name at least HINT_FUZZY_MIN long.
|
|
408
|
+
HINT_MAX_EDITS = 2
|
|
409
|
+
HINT_FUZZY_MIN = 6
|
|
410
|
+
# The most ids a hint names.
|
|
411
|
+
HINT_SUGGESTIONS = 3
|
|
412
|
+
# A 404's model it couldn't find: "Model 'X' not found", "model `X`
|
|
413
|
+
# does not exist", "model X-1 not found" (unquoted, an id-like word,
|
|
414
|
+
# checked after the match). Bounded quantifiers: a server's long token
|
|
415
|
+
# can't make the match quadratic (Ruby 3.3 has no regex memoization).
|
|
416
|
+
MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
|
|
417
|
+
# The ids a 404 lists after "Available models:".
|
|
418
|
+
AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
|
|
419
|
+
# Where that list starts, for the summary to put its hint ahead of it.
|
|
420
|
+
AVAILABLE_MODELS_START_RE = /\s*\bavailable models?:/i
|
|
387
421
|
# The HTTP status an Anthropic error.type stands for, for an error
|
|
388
422
|
# object that carries no numeric code (a Messages stream's error event,
|
|
389
423
|
# an OpenAI error whose code is null or a name).
|
|
@@ -426,6 +460,7 @@ module Samagotchi
|
|
|
426
460
|
else
|
|
427
461
|
OutOfCredits.new(text, **options)
|
|
428
462
|
end
|
|
463
|
+
when 404 then bad_request(text, status, message, options.merge(hint: model_hint(error_message(body, limit: nil))))
|
|
429
464
|
when 400..499 then bad_request(text, status, message, options)
|
|
430
465
|
when 500..599
|
|
431
466
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
@@ -492,7 +527,7 @@ module Samagotchi
|
|
|
492
527
|
# upstream failure (OpenRouter) often says only "Provider returned
|
|
493
528
|
# error" and puts the upstream's words in error.metadata.raw and its
|
|
494
529
|
# name in metadata.provider_name; both are added when present.
|
|
495
|
-
def error_message(body)
|
|
530
|
+
def error_message(body, limit: MESSAGE_LIMIT)
|
|
496
531
|
parsed = parse_json(body.to_s)
|
|
497
532
|
message =
|
|
498
533
|
if parsed.is_a?(Hash)
|
|
@@ -502,9 +537,106 @@ module Samagotchi
|
|
|
502
537
|
message = body.to_s if message.nil? || message.to_s.strip.empty?
|
|
503
538
|
message = message.to_s.strip
|
|
504
539
|
message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
|
|
505
|
-
|
|
540
|
+
return "(empty body)" if message.empty?
|
|
541
|
+
|
|
542
|
+
limit ? message[0, limit] : message
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# "did you mean 'ID'?" for a 404 that names the model it couldn't find
|
|
546
|
+
# and lists the ones it has (oMLX: "Model 'X' not found. Available
|
|
547
|
+
# models: a, b, …"), naming up to HINT_SUGGESTIONS ids
|
|
548
|
+
# (#closest_model_ids: "did you mean 'A' or 'B'?"). Read from the
|
|
549
|
+
# message past the summary's cut (MESSAGE_LIMIT), within the HINT_*
|
|
550
|
+
# bounds; nil when there is no list or nothing close.
|
|
551
|
+
def model_hint(message)
|
|
552
|
+
message = message[0, HINT_SCAN_CHARS]
|
|
553
|
+
match = MODEL_NOT_FOUND_RE.match(message)
|
|
554
|
+
requested = match && (match[1] || match[2])
|
|
555
|
+
return nil unless requested && (match[1] || requested.match?(%r{[-/.:_\d]}))
|
|
556
|
+
|
|
557
|
+
list = message[AVAILABLE_MODELS_RE, 1]
|
|
558
|
+
return nil unless list
|
|
559
|
+
|
|
560
|
+
ids = list.split(/,\s*|\s+/, HINT_IDS_MAX + 1).first(HINT_IDS_MAX)
|
|
561
|
+
.map { |id| id.delete("[]\"'`").sub(/[.;]+\z/, "") }
|
|
562
|
+
.reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
|
|
563
|
+
return nil if ids.empty? || ids.include?(requested)
|
|
564
|
+
|
|
565
|
+
close = closest_model_ids(requested, ids)
|
|
566
|
+
"did you mean #{close.map { |id| "'#{id}'" }.join(" or ")}?" unless close.empty?
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The listed ids closest to +requested+, at most HINT_SUGGESTIONS, in
|
|
570
|
+
# the first tier that has any: the ids that are +requested+ but for
|
|
571
|
+
# case, or whose last segment (after "/" or "--") is; else the ids a
|
|
572
|
+
# typo away (#typo_distance, whole or last segment), the nearest
|
|
573
|
+
# only; else the ids that contain +requested+ (HINT_FUZZY_MIN
|
|
574
|
+
# characters or more) or whose last segment it contains, the
|
|
575
|
+
# shortest first.
|
|
576
|
+
def closest_model_ids(requested, ids)
|
|
577
|
+
probe = requested.downcase
|
|
578
|
+
exact = ids.select { |id| id.downcase == probe || last_segment(id) == probe }
|
|
579
|
+
return exact.first(HINT_SUGGESTIONS) unless exact.empty?
|
|
580
|
+
|
|
581
|
+
scored = ids.filter_map do |id|
|
|
582
|
+
distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
|
|
583
|
+
[distance, id] if distance
|
|
584
|
+
end
|
|
585
|
+
unless scored.empty?
|
|
586
|
+
best = scored.map(&:first).min
|
|
587
|
+
return scored.select { |distance, _| distance == best }.map(&:last).first(HINT_SUGGESTIONS)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
containing_ids(probe, ids)
|
|
506
591
|
end
|
|
507
592
|
|
|
593
|
+
# The ids that contain +probe+ or whose last segment +probe+ contains
|
|
594
|
+
# (either one HINT_FUZZY_MIN characters or more), closest in length
|
|
595
|
+
# first, at most HINT_SUGGESTIONS.
|
|
596
|
+
def containing_ids(probe, ids)
|
|
597
|
+
return [] if probe.length < HINT_FUZZY_MIN
|
|
598
|
+
|
|
599
|
+
ids.select do |id|
|
|
600
|
+
segment = last_segment(id)
|
|
601
|
+
id.downcase.include?(probe) || (segment.length >= HINT_FUZZY_MIN && probe.include?(segment))
|
|
602
|
+
end.each_with_index.sort_by { |id, index| [(id.length - probe.length).abs, index] }
|
|
603
|
+
.first(HINT_SUGGESTIONS).map(&:first)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The edit distance of +a+ and +b+ when it is a typo's: at most
|
|
607
|
+
# HINT_MAX_EDITS and under a third of the shorter one, which has
|
|
608
|
+
# HINT_FUZZY_MIN characters or more; else nil. A banded Levenshtein:
|
|
609
|
+
# linear in the length.
|
|
610
|
+
def typo_distance(a, b)
|
|
611
|
+
shorter = [a.length, b.length].min
|
|
612
|
+
return nil if shorter < HINT_FUZZY_MIN || (a.length - b.length).abs > HINT_MAX_EDITS
|
|
613
|
+
|
|
614
|
+
distance = banded_distance(a, b, HINT_MAX_EDITS)
|
|
615
|
+
distance if distance && distance * 3 < shorter
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Levenshtein distance of +a+ and +b+ if at most +limit+, else nil;
|
|
619
|
+
# only cells within +limit+ of the diagonal are computed.
|
|
620
|
+
def banded_distance(a, b, limit)
|
|
621
|
+
far = limit + 1
|
|
622
|
+
prev = Hash.new(far)
|
|
623
|
+
(0..[limit, b.length].min).each { |j| prev[j] = j }
|
|
624
|
+
a.each_char.with_index(1) do |ca, i|
|
|
625
|
+
cur = Hash.new(far)
|
|
626
|
+
cur[0] = i if i <= limit
|
|
627
|
+
([i - limit, 1].max..[i + limit, b.length].min).each do |j|
|
|
628
|
+
cost = ca == b[j - 1] ? 0 : 1
|
|
629
|
+
cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost, far].min
|
|
630
|
+
end
|
|
631
|
+
return nil if cur.empty? || cur.values.min > limit
|
|
632
|
+
|
|
633
|
+
prev = cur
|
|
634
|
+
end
|
|
635
|
+
prev[b.length] <= limit ? prev[b.length] : nil
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def last_segment(id) = id.downcase.split(%r{/|--}).last
|
|
639
|
+
|
|
508
640
|
# error.metadata.reason of a JSON error body (OpenRouter), or nil.
|
|
509
641
|
def error_reason(body)
|
|
510
642
|
parsed = parse_json(body.to_s)
|