samagotchi 0.5.1 → 0.7.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 +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +11 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -14,6 +14,7 @@ module Samagotchi
|
|
|
14
14
|
COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
|
|
15
15
|
TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
|
|
16
16
|
PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
17
|
+
FAILED_STREAM_HOOK_LOG_SECONDS = 60
|
|
17
18
|
|
|
18
19
|
# @param bundle [String]
|
|
19
20
|
# @param label [String] "<file> (bundle <name>)": what hooks and
|
|
@@ -222,10 +223,17 @@ module Samagotchi
|
|
|
222
223
|
label = @label
|
|
223
224
|
block = hook[:block]
|
|
224
225
|
context = @context
|
|
226
|
+
# A stream hook fires ~once a second: a broken one logs once a minute.
|
|
227
|
+
quiet_for = hook[:event] == :generation_progress ? FAILED_STREAM_HOOK_LOG_SECONDS : 0
|
|
228
|
+
logged_at = nil
|
|
225
229
|
@registries.hooks.register_bundle(bundle, hook[:event], hook_name: label.split(" ").first,
|
|
226
230
|
priority: hook[:priority]) do |event|
|
|
227
231
|
block.call(event, context)
|
|
228
232
|
rescue StandardError => e
|
|
233
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
234
|
+
next if logged_at && now - logged_at < quiet_for
|
|
235
|
+
|
|
236
|
+
logged_at = now
|
|
229
237
|
# Logged only: a turn's live region is on screen.
|
|
230
238
|
Log.warn(:plugins, "plugin_hook_failed", bundle: bundle, event: hook[:event].to_s, error: e.class.name,
|
|
231
239
|
msg: "#{label} #{hook[:event]} hook failed: #{e.message}")
|
|
@@ -18,10 +18,10 @@ module Samagotchi
|
|
|
18
18
|
# the answer text.
|
|
19
19
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
20
20
|
# +model_name+ and +state_dir+ are what ctx.sessions forks with.
|
|
21
|
-
# +steer+ takes (text, source)
|
|
22
|
-
# true when it acted on a running turn.
|
|
21
|
+
# +steer+ takes (text, source), +stop_turn+ and +stop_generation+
|
|
22
|
+
# (reason, label), each true when it acted on a running turn.
|
|
23
23
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
24
|
-
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
|
|
24
|
+
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
|
|
25
25
|
|
|
26
26
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
27
27
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -199,6 +199,15 @@ module Samagotchi
|
|
|
199
199
|
!!@host.stop_turn&.call(reason.to_s, @label)
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
# Cut the generation that is streaming, as a hook's
|
|
203
|
+
# event[:stop_generation] does: the turn goes on and the model is
|
|
204
|
+
# asked again (retry.empty_answer), or with no retry left the turn
|
|
205
|
+
# ends as cancelled (hook). Shows nothing: post your own notice.
|
|
206
|
+
# @return [Boolean] whether a streaming generation was cut now
|
|
207
|
+
def stop_generation(reason)
|
|
208
|
+
!!@host.stop_generation&.call(reason.to_s, @label)
|
|
209
|
+
end
|
|
210
|
+
|
|
202
211
|
private
|
|
203
212
|
|
|
204
213
|
def deep_freeze(value)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cancellation_controller"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "plugin/api"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# An Engine's plugin init tasks (chi.init) and the tool sets plugins stage
|
|
9
|
+
# (chi.replace_tools) for the turn thread.
|
|
10
|
+
#
|
|
11
|
+
# A plugin's slow setup (docs/plugins.md, Init tasks): run on its own
|
|
12
|
+
# thread once the owner can show it (#start!), announced as
|
|
13
|
+
# plugin_init_started / plugin_init_finished unless quiet. A turn waits
|
|
14
|
+
# for the running ones that provide tools before its first model request
|
|
15
|
+
# (#await), up to each one's timeout.
|
|
16
|
+
#
|
|
17
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
18
|
+
# Lock order: the Engine's lifecycle lock may be held when this one is
|
|
19
|
+
# taken (#shut_down!), the event lock too (#running); this lock is never
|
|
20
|
+
# held while the event lock is taken.
|
|
21
|
+
class PluginTasks
|
|
22
|
+
# +cancel+ is the task's own controller, cancelled only by #shut_down!:
|
|
23
|
+
# a Ctrl-C ends a turn's wait, not the task.
|
|
24
|
+
InitTask = Struct.new(:id, :bundle, :label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block,
|
|
25
|
+
:cancel, :thread, :state, :started_at, keyword_init: true) do
|
|
26
|
+
# What the task's block reads: whether chi is shutting down.
|
|
27
|
+
def cancelled? = cancel.cancelled?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# How long a task may hold a turn when it gives no timeout.
|
|
31
|
+
INIT_TASK_TIMEOUT = 60.0
|
|
32
|
+
|
|
33
|
+
INIT_WAIT_POLL = 0.05
|
|
34
|
+
|
|
35
|
+
# @param clock [#call] → Float, monotonic seconds
|
|
36
|
+
# @param synchronize_events [#call] (&block) → runs the block holding the event log
|
|
37
|
+
# @param announce [#call] (event) → announces an event
|
|
38
|
+
# @param emit [#call] (sink, event) → emits to a turn's sink + observers
|
|
39
|
+
# @param show_card [#call] (**card) → shows a card
|
|
40
|
+
# @param tools [#call] → Tools::Registry
|
|
41
|
+
# @param notify [#call] (text, level, source) → a notice
|
|
42
|
+
# @param tools_changed [#call] → the tools changed (prompts built again)
|
|
43
|
+
def initialize(clock:, synchronize_events:, announce:, emit:, show_card:, tools:, notify:, tools_changed:)
|
|
44
|
+
@clock = clock
|
|
45
|
+
@synchronize_events = synchronize_events
|
|
46
|
+
@announce = announce
|
|
47
|
+
@emit = emit
|
|
48
|
+
@show_card = show_card
|
|
49
|
+
@tools = tools
|
|
50
|
+
@notify = notify
|
|
51
|
+
@tools_changed = tools_changed
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
@shut_down = false
|
|
54
|
+
@tasks = []
|
|
55
|
+
# Plugins' tool sets from chi.replace_tools, by bundle, until the
|
|
56
|
+
# turn thread applies them (#apply_staged_tools!).
|
|
57
|
+
@staged_tools = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Add a plugin's init task (Plugin::Api#init at commit); it starts with
|
|
61
|
+
# #start!.
|
|
62
|
+
def add(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@tasks << InitTask.new(id: "#{bundle}-#{@tasks.size + 1}", bundle: bundle.to_s, label: label.to_s,
|
|
65
|
+
plugin_label: plugin_label, provides_tools: provides_tools ? true : false,
|
|
66
|
+
quiet: quiet ? true : false, timeout: timeout || INIT_TASK_TIMEOUT, failed: failed,
|
|
67
|
+
block: block, cancel: CancellationController.new, state: :pending)
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Start the init tasks not started yet, each on its own thread. The
|
|
73
|
+
# worker calls it once its Bridge is up, the REPL once it renders
|
|
74
|
+
# events, and every turn (a -p run has only that); later calls start
|
|
75
|
+
# nothing new.
|
|
76
|
+
def start!
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
return if @shut_down
|
|
79
|
+
|
|
80
|
+
@tasks.each do |task|
|
|
81
|
+
next unless task.state == :pending
|
|
82
|
+
|
|
83
|
+
task.state = :starting
|
|
84
|
+
task.started_at = @clock.call
|
|
85
|
+
task.thread = Thread.new { run(task) }
|
|
86
|
+
task.thread.report_on_exception = false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The running init tasks a UI shows (not the quiet ones), for a UI that
|
|
93
|
+
# joins while they run (Bridge#snapshot, with the event log held).
|
|
94
|
+
# @return [Array<Hash>] {bundle:, id:, label:}
|
|
95
|
+
def running
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@tasks.select { |task| task.state == :running && !task.quiet }
|
|
98
|
+
.map { |task| { bundle: task.bundle, id: task.id, label: task.label } }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Wait for the running init tasks that provide tools, each up to its
|
|
103
|
+
# timeout from its start, while +controller+ isn't cancelled; tell the
|
|
104
|
+
# turn's sink what it waits for (:plugin_init_wait). A task that ends
|
|
105
|
+
# late or fails leaves the turn without its tools.
|
|
106
|
+
# @return [Boolean] whether it waited
|
|
107
|
+
def await(controller = nil, on_event = nil)
|
|
108
|
+
waiting = @mutex.synchronize do
|
|
109
|
+
@tasks.select { |task| task.provides_tools && %i[starting running].include?(task.state) }
|
|
110
|
+
end
|
|
111
|
+
return false if waiting.empty?
|
|
112
|
+
|
|
113
|
+
@emit.call(on_event, { type: :plugin_init_wait,
|
|
114
|
+
tasks: waiting.map { |task| { bundle: task.bundle, id: task.id, label: task.label } } })
|
|
115
|
+
started = @clock.call
|
|
116
|
+
loop do
|
|
117
|
+
now = @clock.call
|
|
118
|
+
left = waiting.select { |task| %i[starting running].include?(task.state) && now < task.started_at + task.timeout }
|
|
119
|
+
break if left.empty? || controller&.cancelled?
|
|
120
|
+
|
|
121
|
+
sleep(INIT_WAIT_POLL)
|
|
122
|
+
end
|
|
123
|
+
Log.info(:plugins, "init_wait", ms: ((@clock.call - started) * 1000).round,
|
|
124
|
+
cancelled: controller&.cancelled? ? true : nil)
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The init task this thread runs, or nil.
|
|
129
|
+
def current = Thread.current[:"samagotchi_init_#{object_id}"]
|
|
130
|
+
|
|
131
|
+
# Keep a plugin's new tool set (chi.replace_tools, from any thread)
|
|
132
|
+
# for the turn thread, which applies it (#apply_staged_tools!): the
|
|
133
|
+
# registry is read only there. A later set of the same bundle wins.
|
|
134
|
+
def stage_tools(bundle, specs, context)
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
return if @shut_down
|
|
137
|
+
|
|
138
|
+
@staged_tools[bundle] = [specs, context]
|
|
139
|
+
end
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Apply the staged tool sets (#stage_tools), on the turn thread, before
|
|
144
|
+
# the turn's system prompt is built; the prompts are built again when
|
|
145
|
+
# a set changed anything. A name another source has is left out with a
|
|
146
|
+
# notice.
|
|
147
|
+
# @return [Boolean] whether the tools changed
|
|
148
|
+
def apply_staged_tools!
|
|
149
|
+
staged = @mutex.synchronize do
|
|
150
|
+
taken = @staged_tools
|
|
151
|
+
@staged_tools = {}
|
|
152
|
+
taken
|
|
153
|
+
end
|
|
154
|
+
changed = false
|
|
155
|
+
staged.each do |bundle, (specs, context)|
|
|
156
|
+
result = Plugin::Api.apply_tools(@tools.call, bundle, specs, context)
|
|
157
|
+
changed ||= result[:changed]
|
|
158
|
+
result[:skipped].each do |why|
|
|
159
|
+
Log.warn(:plugins, "plugin_tool_skipped", bundle: bundle, msg: why)
|
|
160
|
+
@notify.call("#{why}; left out", :warn, bundle)
|
|
161
|
+
end
|
|
162
|
+
Log.info(:plugins, "plugin_tools_replaced", bundle: bundle, tools: specs.size) if result[:changed]
|
|
163
|
+
end
|
|
164
|
+
@tools_changed.call if changed
|
|
165
|
+
changed
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Shutting down (Engine#shutdown, under its lifecycle lock): nothing
|
|
169
|
+
# starts or stages after this; an init task's requests end (a server's
|
|
170
|
+
# boot), so it finishes.
|
|
171
|
+
# @return [Array<Thread>] the task threads, to join
|
|
172
|
+
def shut_down!
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
@shut_down = true
|
|
175
|
+
@tasks.each { |task| task.cancel.cancel!(:shutdown) }
|
|
176
|
+
@tasks.filter_map(&:thread)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# After the joins: the task threads still alive are killed.
|
|
181
|
+
def kill_leftovers
|
|
182
|
+
@tasks.each { |task| task.thread&.kill if task.thread&.alive? }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [Array<InitTask>] a copy of the task list (specs)
|
|
186
|
+
def tasks
|
|
187
|
+
@mutex.synchronize { @tasks.dup }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def run(task)
|
|
193
|
+
Thread.current[:"samagotchi_init_#{object_id}"] = task
|
|
194
|
+
@synchronize_events.call do
|
|
195
|
+
task.state = :running
|
|
196
|
+
@announce.call({ type: :plugin_init_started, bundle: task.bundle, id: task.id, label: task.label }) unless task.quiet
|
|
197
|
+
end
|
|
198
|
+
Log.info(:plugins, "init_started", bundle: task.bundle, id: task.id, label: task.label)
|
|
199
|
+
summary = task.block.call(task)
|
|
200
|
+
finish(task, ok: true, summary: summary.is_a?(String) ? summary : nil)
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
finish(task, ok: false, error: e.message)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def finish(task, ok:, summary: nil, error: nil)
|
|
206
|
+
Log.public_send(ok ? :info : :warn, :plugins, "init_finished", bundle: task.bundle, id: task.id, ok: ok,
|
|
207
|
+
ms: ((@clock.call - task.started_at) * 1000).round,
|
|
208
|
+
msg: error)
|
|
209
|
+
shut_down = @mutex.synchronize { @shut_down }
|
|
210
|
+
@synchronize_events.call do
|
|
211
|
+
task.state = ok ? :done : :failed
|
|
212
|
+
next if shut_down || (task.quiet && ok)
|
|
213
|
+
|
|
214
|
+
unless task.quiet
|
|
215
|
+
@announce.call({ type: :plugin_init_finished, bundle: task.bundle, id: task.id, label: task.label, ok: ok,
|
|
216
|
+
summary: summary, error: error }.compact)
|
|
217
|
+
end
|
|
218
|
+
# A failure stays on screen (and for a UI that joins later) as a
|
|
219
|
+
# card: a short title (the card shows the bundle beside it), the
|
|
220
|
+
# detail in the body.
|
|
221
|
+
unless ok
|
|
222
|
+
title = task.failed || "setup failed"
|
|
223
|
+
body = task.failed ? error.to_s : "#{task.label}: #{error}"
|
|
224
|
+
@show_card.call(source: task.bundle, title: title, body: body, level: :warn, id: "init-#{task.id}")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "log"
|
|
9
|
+
require_relative "tools/ask_user_question"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# An Engine's question flow: the one open question (ask_user_question, a
|
|
13
|
+
# hook's ask_user, a guardrail approval), its answer and its cancel, shared
|
|
14
|
+
# by every UI. The turn thread blocks in #open_question; the UIs answer or
|
|
15
|
+
# cancel from theirs (a REPL answers inline through the sync handler).
|
|
16
|
+
#
|
|
17
|
+
# What it needs from the Engine comes through lookups, read at call time.
|
|
18
|
+
class QuestionDesk
|
|
19
|
+
# Raised by #answer when the question it targets is no longer open
|
|
20
|
+
# (never asked, superseded, already answered or cancelled). A subclass
|
|
21
|
+
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
22
|
+
class NotPending < ArgumentError; end
|
|
23
|
+
|
|
24
|
+
DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
|
|
25
|
+
"or ask in your reply if you can't."
|
|
26
|
+
|
|
27
|
+
# @param session [#call] → Session, nil
|
|
28
|
+
# @param state_dir [#call] → String, the session's state dir
|
|
29
|
+
# @param emit [#call] (event) → emits an event to the turn sink + observers
|
|
30
|
+
# @param cancel_controller [#call] → CancellationController, nil (the running turn's)
|
|
31
|
+
# @param interface [#call] → Symbol, the Engine's interface
|
|
32
|
+
# @param user_input [#call] (session_id) → a human answered (ArchiveStore)
|
|
33
|
+
def initialize(session:, state_dir:, emit:, cancel_controller:, interface:, user_input:)
|
|
34
|
+
@session_lookup = session
|
|
35
|
+
@state_dir_lookup = state_dir
|
|
36
|
+
@emit = emit
|
|
37
|
+
@cancel_controller_lookup = cancel_controller
|
|
38
|
+
@interface_lookup = interface
|
|
39
|
+
@user_input = user_input
|
|
40
|
+
# A Monitor: re-entered by the specs from the same thread.
|
|
41
|
+
@lock = Monitor.new
|
|
42
|
+
@cv = @lock.new_cond
|
|
43
|
+
@pending = nil
|
|
44
|
+
@answer = nil
|
|
45
|
+
@sync_handler = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Hash, nil] current pending question (thread-safe copy)
|
|
49
|
+
def pending
|
|
50
|
+
@lock.synchronize { @pending&.dup }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Ask the model's question (ask_user_question): the kernel's
|
|
54
|
+
# question_handler, called on the turn thread with the payload
|
|
55
|
+
# Tools::AskUserQuestion.validate made. Opens it (#open_question) and
|
|
56
|
+
# returns the answer as JSON for the tool result.
|
|
57
|
+
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
58
|
+
# @return [String] normalized answer JSON
|
|
59
|
+
def request(payload)
|
|
60
|
+
result = open_question(**payload.slice(:question, :options, :header),
|
|
61
|
+
multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
|
|
62
|
+
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
63
|
+
# tool failure the model learns to avoid the tool from.
|
|
64
|
+
result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
65
|
+
result.is_a?(String) ? result : JSON.generate(result)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Open a question for the UIs and wait for its answer. Emits
|
|
69
|
+
# :question_requested, persists it to the session, and BLOCKS until
|
|
70
|
+
# answer / cancel wakes it (or the turn is cancelled).
|
|
71
|
+
# The fields go to pending as given (no cleaning), extra keys
|
|
72
|
+
# included, so a caller can add its own (kind:, approval:).
|
|
73
|
+
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
74
|
+
# @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
|
|
75
|
+
# or {error:, …}; a String when a sync handler returned text itself
|
|
76
|
+
def open_question(fields)
|
|
77
|
+
id = SecureRandom.uuid
|
|
78
|
+
pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
|
|
79
|
+
|
|
80
|
+
@lock.synchronize do
|
|
81
|
+
@pending = pending
|
|
82
|
+
@answer = nil
|
|
83
|
+
end
|
|
84
|
+
# Persist to session file for WEB stub + resume (generic for all UIs)
|
|
85
|
+
if session
|
|
86
|
+
session.pending_question = pending.dup
|
|
87
|
+
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
88
|
+
end
|
|
89
|
+
# Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
|
|
90
|
+
# stash this event (e.g. TerminalUI handle_question_event) will
|
|
91
|
+
# discard it as stale if the synchronous handler below already answers
|
|
92
|
+
# and clears pending — see drain_pending_question? staleness check.
|
|
93
|
+
emit({ type: :question_requested, pending_question: pending })
|
|
94
|
+
|
|
95
|
+
# If a synchronous UI handler is registered (TUI), invoke it inline on the
|
|
96
|
+
# SAME thread that called request_question (TerminalUI's REPL thread is the
|
|
97
|
+
# turn thread — no second thread exists to answer). This avoids deadlock.
|
|
98
|
+
# This path is TUI-specific but the surrounding emit/clear is generic, so
|
|
99
|
+
# any future UI that registers a sync handler gets the same guarantee.
|
|
100
|
+
if @sync_handler
|
|
101
|
+
begin
|
|
102
|
+
sync_res = @sync_handler.call(pending.dup)
|
|
103
|
+
# Handler may have called answer_question or returned a hash/string.
|
|
104
|
+
# Decided under the lock; saved and announced after it (never emit
|
|
105
|
+
# holding the question lock: a snapshot holds the event lock and
|
|
106
|
+
# then reads #pending).
|
|
107
|
+
ans = @lock.synchronize do
|
|
108
|
+
if @answer
|
|
109
|
+
@pending = nil
|
|
110
|
+
@answer
|
|
111
|
+
elsif sync_res.is_a?(Hash) && sync_res[:selected]
|
|
112
|
+
# Treat returned hash as answer (handler rendered and parsed)
|
|
113
|
+
@answer = sync_res
|
|
114
|
+
@pending = nil
|
|
115
|
+
sync_res
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
if ans
|
|
119
|
+
clear_saved_question
|
|
120
|
+
emit({ type: :question_answered, id: id, answer: ans })
|
|
121
|
+
return ans
|
|
122
|
+
end
|
|
123
|
+
return sync_res if sync_res.is_a?(String) && !sync_res.strip.empty?
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
|
|
126
|
+
end
|
|
127
|
+
# Sync handler existed but did not produce an answer — do not deadlock on
|
|
128
|
+
# CV (no cross-thread answerer exists for synchronous UIs). Clear pending
|
|
129
|
+
# and return an error so the model can fallback to plain text. Generic
|
|
130
|
+
# observers will discard the stale question_requested via staleness check.
|
|
131
|
+
@lock.synchronize { @pending = nil }
|
|
132
|
+
clear_saved_question
|
|
133
|
+
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
137
|
+
answer = nil
|
|
138
|
+
cancelled_reason = nil
|
|
139
|
+
@lock.synchronize do
|
|
140
|
+
loop do
|
|
141
|
+
break if @answer
|
|
142
|
+
break if cancel_controller&.cancelled?
|
|
143
|
+
break if @pending.nil? || @pending[:status] != "pending"
|
|
144
|
+
|
|
145
|
+
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
146
|
+
@cv.wait(0.2)
|
|
147
|
+
end
|
|
148
|
+
answer = @answer
|
|
149
|
+
controller = cancel_controller
|
|
150
|
+
cancelled_reason = controller.reason.to_s if answer.nil? && controller&.cancelled?
|
|
151
|
+
@pending = nil
|
|
152
|
+
end
|
|
153
|
+
# Saved and announced outside the question lock (see the sync path).
|
|
154
|
+
clear_saved_question
|
|
155
|
+
if cancelled_reason
|
|
156
|
+
emit({ type: :question_cancelled, id: id, reason: cancelled_reason })
|
|
157
|
+
return { error: "cancelled", reason: cancelled_reason, id: id }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if answer
|
|
161
|
+
emit({ type: :question_answered, id: id, answer: answer })
|
|
162
|
+
answer
|
|
163
|
+
else
|
|
164
|
+
{ error: "no answer", id: id }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Answer the pending question (called from UI thread).
|
|
169
|
+
# @param id [String] pending id
|
|
170
|
+
# @param selected [Array<String>] values/labels
|
|
171
|
+
# @param freeform [String, nil]
|
|
172
|
+
# @return [Hash] normalized answer
|
|
173
|
+
def answer(id:, selected:, freeform: nil)
|
|
174
|
+
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
175
|
+
fm = freeform.to_s.strip
|
|
176
|
+
fm = nil if fm.empty?
|
|
177
|
+
@lock.synchronize do
|
|
178
|
+
pending = @pending
|
|
179
|
+
raise NotPending, "no pending question" unless pending
|
|
180
|
+
raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
|
|
181
|
+
# First responder wins: after the first answer the turn thread clears
|
|
182
|
+
# @pending in a later lock block, so a second UI's answer can
|
|
183
|
+
# land in between and must not overwrite the first.
|
|
184
|
+
raise NotPending, "question already answered" if @answer
|
|
185
|
+
raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
|
|
186
|
+
|
|
187
|
+
opts = Array(pending[:options])
|
|
188
|
+
# Validate selected subset of options (value == label in v1)
|
|
189
|
+
invalid = sel.reject { |v| opts.include?(v) }
|
|
190
|
+
unless invalid.empty?
|
|
191
|
+
raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
|
|
192
|
+
end
|
|
193
|
+
if !pending[:multi_select] && sel.size > 1
|
|
194
|
+
raise ArgumentError, "single-select question: got #{sel.size} selections"
|
|
195
|
+
end
|
|
196
|
+
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
197
|
+
raise ArgumentError, "selection required"
|
|
198
|
+
end
|
|
199
|
+
# Persist pending cleared elsewhere; just set answer
|
|
200
|
+
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
201
|
+
# Derive indices for convenience
|
|
202
|
+
answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
|
|
203
|
+
@answer = answer
|
|
204
|
+
@cv.broadcast
|
|
205
|
+
answer
|
|
206
|
+
end.tap do
|
|
207
|
+
# A human answered: the session is back in the lists (ArchiveStore).
|
|
208
|
+
@user_input.call(session&.id)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def sync_handler=(block)
|
|
213
|
+
@sync_handler = block
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Cancel the pending question (e.g. /cancel, a dismiss). Announces which
|
|
217
|
+
# one, so every UI closes it; with none pending there is nothing to
|
|
218
|
+
# announce. A question already answered (the turn thread hasn't taken the
|
|
219
|
+
# answer yet) or already closed stays as it is: the first responder wins.
|
|
220
|
+
# @param id [String, nil] cancel only this question (a UI's dismiss
|
|
221
|
+
# names the one it showed)
|
|
222
|
+
# @return [Boolean] whether it was cancelled (true with none pending and
|
|
223
|
+
# no id, as before)
|
|
224
|
+
def cancel(reason = "user", id: nil)
|
|
225
|
+
cancelled_id = @lock.synchronize do
|
|
226
|
+
pending = @pending
|
|
227
|
+
next unless pending
|
|
228
|
+
next if id && pending[:id].to_s != id.to_s
|
|
229
|
+
next if @answer || pending[:status].to_s != "pending"
|
|
230
|
+
|
|
231
|
+
pending[:status] = "cancelled"
|
|
232
|
+
@cv.broadcast
|
|
233
|
+
pending[:id]
|
|
234
|
+
end
|
|
235
|
+
return id.nil? && self.pending.nil? unless cancelled_id
|
|
236
|
+
|
|
237
|
+
emit({ type: :question_cancelled, id: cancelled_id, reason: reason.to_s }) rescue nil
|
|
238
|
+
true
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A question through the question flow (REPL sync handler, attached TUI,
|
|
242
|
+
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
243
|
+
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
244
|
+
# answer, cancelled) is nil too.
|
|
245
|
+
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
246
|
+
def ask_for_hook(question, options, header, allow_freeform, hook)
|
|
247
|
+
return nil if @interface_lookup.call == :non_interactive
|
|
248
|
+
|
|
249
|
+
opts = Tools::AskUserQuestion.normalize_options(options)
|
|
250
|
+
unless opts
|
|
251
|
+
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
|
|
252
|
+
return nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
256
|
+
allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
|
|
257
|
+
answer = open_question(fields)
|
|
258
|
+
return nil unless answer.is_a?(Hash) && answer[:selected]
|
|
259
|
+
|
|
260
|
+
result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
|
|
261
|
+
result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
|
|
262
|
+
result
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def session = @session_lookup.call
|
|
268
|
+
def state_dir = @state_dir_lookup.call
|
|
269
|
+
def cancel_controller = @cancel_controller_lookup.call
|
|
270
|
+
def emit(event) = @emit.call(event)
|
|
271
|
+
|
|
272
|
+
# The session file no longer holds a pending question.
|
|
273
|
+
def clear_saved_question
|
|
274
|
+
return unless session
|
|
275
|
+
|
|
276
|
+
session.pending_question = nil
|
|
277
|
+
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|