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
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -33,6 +33,7 @@ require_relative "system_prompt"
|
|
|
33
33
|
require_relative "session_metrics"
|
|
34
34
|
require_relative "token_usage"
|
|
35
35
|
require_relative "idle_recap"
|
|
36
|
+
require_relative "recap_setup"
|
|
36
37
|
require_relative "idle_reminders"
|
|
37
38
|
require_relative "idle_scheduler"
|
|
38
39
|
require_relative "hooks"
|
|
@@ -50,6 +51,9 @@ require_relative "sampling_settings"
|
|
|
50
51
|
require_relative "thinking"
|
|
51
52
|
require_relative "answer_display"
|
|
52
53
|
require_relative "edit_preview"
|
|
54
|
+
require_relative "question_desk"
|
|
55
|
+
require_relative "guardrail_wiring"
|
|
56
|
+
require_relative "plugin_tasks"
|
|
53
57
|
|
|
54
58
|
module Samagotchi
|
|
55
59
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -61,7 +65,7 @@ module Samagotchi
|
|
|
61
65
|
# Raised by #answer_question when the question it targets is no longer
|
|
62
66
|
# open (never asked, superseded, already answered or cancelled). A subclass
|
|
63
67
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
64
|
-
|
|
68
|
+
QuestionNotPending = QuestionDesk::NotPending
|
|
65
69
|
|
|
66
70
|
# Build a system prompt string for the given profile.
|
|
67
71
|
# Used by specs and inspection.
|
|
@@ -118,11 +122,31 @@ module Samagotchi
|
|
|
118
122
|
# Load hooks from config (plugins) and create the registry; what fails
|
|
119
123
|
# to load is announced, and a required guardrail's failure denies
|
|
120
124
|
# every tool call. Rules load now too, so their errors are announced.
|
|
121
|
-
@
|
|
122
|
-
|
|
125
|
+
@guardrail_wiring = GuardrailWiring.new(
|
|
126
|
+
scratch: @scratch,
|
|
127
|
+
hooks: -> { @hooks },
|
|
128
|
+
tools: -> { @tools },
|
|
129
|
+
session: -> { @session },
|
|
130
|
+
model_key: -> { @model_key },
|
|
131
|
+
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
132
|
+
ask: ->(fields) { open_question(fields) }
|
|
133
|
+
)
|
|
134
|
+
@guardrail_failures = @guardrail_wiring.failures
|
|
123
135
|
# Plugins that failed to load: announced apart, as plugins (not
|
|
124
136
|
# guardrails: no tool call is denied for them).
|
|
125
137
|
@plugin_failures = Guardrails::LoadFailures.new
|
|
138
|
+
# The question flow (ask_user_question, hooks' and plugins' ask_user,
|
|
139
|
+
# approvals). Built before the hooks and plugins load: a plugin can ask
|
|
140
|
+
# during its load (answered nil then: the interface is still
|
|
141
|
+
# :non_interactive).
|
|
142
|
+
@question_desk = QuestionDesk.new(
|
|
143
|
+
session: -> { @session },
|
|
144
|
+
state_dir: -> { session_state_dir },
|
|
145
|
+
emit: ->(event) { emit_event(nil, event) },
|
|
146
|
+
cancel_controller: -> { active_cancel_controller },
|
|
147
|
+
interface: -> { interface },
|
|
148
|
+
user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
|
|
149
|
+
)
|
|
126
150
|
@hooks = load_hooks_from_config
|
|
127
151
|
load_hooks_from_bundles
|
|
128
152
|
# The tools this session offers (the prompts' declarations and the
|
|
@@ -143,11 +167,19 @@ module Samagotchi
|
|
|
143
167
|
# commands running now (#spawn_anytime), which it waits for.
|
|
144
168
|
@services = Plugin::Services.new
|
|
145
169
|
@anytime_threads = []
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
# chi.init tasks (#add_init_task), started by #start_init_tasks!, and
|
|
171
|
+
# plugins' tool sets from chi.replace_tools until the turn thread
|
|
172
|
+
# applies them (#apply_staged_tools!). Built before the plugins load.
|
|
173
|
+
@plugin_tasks = PluginTasks.new(
|
|
174
|
+
clock: -> { monotonic_now },
|
|
175
|
+
synchronize_events: ->(&block) { synchronize_events(&block) },
|
|
176
|
+
announce: ->(event) { announce(event) },
|
|
177
|
+
emit: ->(sink, event) { emit_event(sink, event) },
|
|
178
|
+
show_card: ->(**card) { show_card(**card) },
|
|
179
|
+
tools: -> { @tools },
|
|
180
|
+
notify: ->(text, level, source) { hook_notify(text, level, source) },
|
|
181
|
+
tools_changed: -> { tools_changed! }
|
|
182
|
+
)
|
|
151
183
|
@lifecycle_mutex = Mutex.new
|
|
152
184
|
@shut_down = false
|
|
153
185
|
load_plugins if plugins
|
|
@@ -190,30 +222,13 @@ module Samagotchi
|
|
|
190
222
|
@kernel.question_handler = proc { |payload| request_question(payload) } if @kernel.respond_to?(:question_handler=)
|
|
191
223
|
# Every tool call asks this gate first. The kernel is never rebuilt, so
|
|
192
224
|
# it holds across model switches.
|
|
193
|
-
@guardrail_git = Guardrails::GitInfo.new
|
|
194
225
|
self.guardrail_state_dir = Session.default_state_dir
|
|
195
226
|
# list_sessions and send_note speak for whichever session runs now.
|
|
196
227
|
@kernel.peers = PeerView.new(self) if @kernel.respond_to?(:peers=)
|
|
197
|
-
if @kernel.respond_to?(:guardrail_gate=)
|
|
198
|
-
@kernel.guardrail_gate = Guardrails::Gate.new(
|
|
199
|
-
-> { @hooks },
|
|
200
|
-
context_lookup: -> { guardrail_context },
|
|
201
|
-
model_key_lookup: -> { @model_key },
|
|
202
|
-
approver: ->(verdict) { request_approval(verdict) },
|
|
203
|
-
approvals_lookup: -> { @guardrail_approvals },
|
|
204
|
-
checks_lookup: -> { guardrail_checks },
|
|
205
|
-
cancelled_lookup: -> { !!active_cancel_controller&.cancelled? },
|
|
206
|
-
tools_lookup: -> { @tools }
|
|
207
|
-
)
|
|
208
|
-
end
|
|
228
|
+
@kernel.guardrail_gate = @guardrail_wiring.gate if @kernel.respond_to?(:guardrail_gate=)
|
|
209
229
|
# What a hook can do beyond reading its event (event[:notify],
|
|
210
230
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
211
231
|
@hooks.runtime = hook_runtime
|
|
212
|
-
# If session was resumed and has a pending_question, hydrate engine state
|
|
213
|
-
if @resume_session && @resume_session.pending_question
|
|
214
|
-
@pending_question = @resume_session.pending_question.dup
|
|
215
|
-
@session = @resume_session
|
|
216
|
-
end
|
|
217
232
|
# The loop follows the effective model's host (its api:): the raw-prompt
|
|
218
233
|
# NativeBackend, or the chat backend for openai hosts.
|
|
219
234
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
@@ -248,11 +263,9 @@ module Samagotchi
|
|
|
248
263
|
# Reminder names the interactive REPL's IdleReminders callback marked due;
|
|
249
264
|
# the REPL polls them to decide when to run a synthetic reminder turn.
|
|
250
265
|
@due_reminder_names = []
|
|
251
|
-
@
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
@question_answer = nil
|
|
255
|
-
@recap = build_recap(recap)
|
|
266
|
+
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
267
|
+
session_target: -> { session_model_recap_target },
|
|
268
|
+
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
256
269
|
# One shared poller for the whole idle layer (reminders + optional
|
|
257
270
|
# recap). Both jobs read the activity seam above; the scheduler owns
|
|
258
271
|
# the single background thread and isolates per-job failures.
|
|
@@ -587,135 +600,41 @@ module Samagotchi
|
|
|
587
600
|
|
|
588
601
|
# ── Plugin init tasks (chi.init) ──────────────────────────────────────
|
|
589
602
|
|
|
590
|
-
# A plugin's slow setup (docs/plugins.md, Init tasks):
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
# (#await_init_tasks), up to each one's timeout. +cancel+ is its own
|
|
595
|
-
# controller, cancelled only by #shutdown: a Ctrl-C ends a turn's wait,
|
|
596
|
-
# not the task.
|
|
597
|
-
InitTask = Struct.new(:id, :bundle, :label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block,
|
|
598
|
-
:cancel, :thread, :state, :started_at, keyword_init: true) do
|
|
599
|
-
# What the task's block reads: whether chi is shutting down.
|
|
600
|
-
def cancelled? = cancel.cancelled?
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# How long a task may hold a turn when it gives no timeout.
|
|
604
|
-
INIT_TASK_TIMEOUT = 60.0
|
|
603
|
+
# A plugin's slow setup (docs/plugins.md, Init tasks): PluginTasks.
|
|
604
|
+
InitTask = PluginTasks::InitTask
|
|
605
|
+
INIT_TASK_TIMEOUT = PluginTasks::INIT_TASK_TIMEOUT
|
|
606
|
+
INIT_WAIT_POLL = PluginTasks::INIT_WAIT_POLL
|
|
605
607
|
|
|
606
608
|
# Add a plugin's init task (Plugin::Api#init at commit); it starts with
|
|
607
609
|
# #start_init_tasks!.
|
|
608
610
|
def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
|
|
609
|
-
@
|
|
610
|
-
|
|
611
|
-
plugin_label: plugin_label, provides_tools: provides_tools ? true : false,
|
|
612
|
-
quiet: quiet ? true : false, timeout: timeout || INIT_TASK_TIMEOUT, failed: failed,
|
|
613
|
-
block: block, cancel: CancellationController.new, state: :pending)
|
|
614
|
-
end
|
|
615
|
-
nil
|
|
611
|
+
@plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
|
|
612
|
+
quiet: quiet, timeout: timeout, failed: failed, &block)
|
|
616
613
|
end
|
|
617
614
|
|
|
618
|
-
# Start the init tasks not started yet, each on its own thread
|
|
619
|
-
#
|
|
620
|
-
# events, and every turn (a -p run has only that); later calls start
|
|
621
|
-
# nothing new.
|
|
615
|
+
# Start the init tasks not started yet, each on its own thread
|
|
616
|
+
# (PluginTasks#start!).
|
|
622
617
|
def start_init_tasks!
|
|
623
|
-
@
|
|
624
|
-
return if @shut_down
|
|
625
|
-
|
|
626
|
-
@init_tasks.each do |task|
|
|
627
|
-
next unless task.state == :pending
|
|
628
|
-
|
|
629
|
-
task.state = :starting
|
|
630
|
-
task.started_at = monotonic_now
|
|
631
|
-
task.thread = Thread.new { run_init_task(task) }
|
|
632
|
-
task.thread.report_on_exception = false
|
|
633
|
-
end
|
|
634
|
-
end
|
|
635
|
-
nil
|
|
618
|
+
@plugin_tasks.start!
|
|
636
619
|
end
|
|
637
620
|
|
|
638
621
|
# The running init tasks a UI shows (not the quiet ones), for a UI that
|
|
639
622
|
# joins while they run (Bridge#snapshot, with the event log held).
|
|
640
623
|
# @return [Array<Hash>] {bundle:, id:, label:}
|
|
641
624
|
def init_tasks
|
|
642
|
-
@
|
|
643
|
-
@init_tasks.select { |task| task.state == :running && !task.quiet }
|
|
644
|
-
.map { |task| { bundle: task.bundle, id: task.id, label: task.label } }
|
|
645
|
-
end
|
|
625
|
+
@plugin_tasks.running
|
|
646
626
|
end
|
|
647
627
|
|
|
648
|
-
# Wait for the running init tasks that provide tools
|
|
649
|
-
# timeout from its start, while +controller+ isn't cancelled; tell the
|
|
650
|
-
# turn's sink what it waits for (:plugin_init_wait). A task that ends
|
|
651
|
-
# late or fails leaves the turn without its tools.
|
|
628
|
+
# Wait for the running init tasks that provide tools (PluginTasks#await).
|
|
652
629
|
# @return [Boolean] whether it waited
|
|
653
630
|
def await_init_tasks(controller = nil, on_event = nil)
|
|
654
|
-
|
|
655
|
-
@init_tasks.select { |task| task.provides_tools && %i[starting running].include?(task.state) }
|
|
656
|
-
end
|
|
657
|
-
return false if waiting.empty?
|
|
658
|
-
|
|
659
|
-
emit_event(on_event, { type: :plugin_init_wait,
|
|
660
|
-
tasks: waiting.map { |task| { bundle: task.bundle, id: task.id, label: task.label } } })
|
|
661
|
-
started = monotonic_now
|
|
662
|
-
loop do
|
|
663
|
-
now = monotonic_now
|
|
664
|
-
left = waiting.select { |task| %i[starting running].include?(task.state) && now < task.started_at + task.timeout }
|
|
665
|
-
break if left.empty? || controller&.cancelled?
|
|
666
|
-
|
|
667
|
-
sleep(INIT_WAIT_POLL)
|
|
668
|
-
end
|
|
669
|
-
Log.info(:plugins, "init_wait", ms: ((monotonic_now - started) * 1000).round,
|
|
670
|
-
cancelled: controller&.cancelled? ? true : nil)
|
|
671
|
-
true
|
|
631
|
+
@plugin_tasks.await(controller, on_event)
|
|
672
632
|
end
|
|
673
633
|
|
|
674
|
-
INIT_WAIT_POLL = 0.05
|
|
675
|
-
|
|
676
634
|
# The init task this thread runs, or nil.
|
|
677
|
-
def current_init_task =
|
|
635
|
+
def current_init_task = @plugin_tasks.current
|
|
678
636
|
private :current_init_task
|
|
679
637
|
|
|
680
|
-
def run_init_task(task)
|
|
681
|
-
Thread.current[:"samagotchi_init_#{object_id}"] = task
|
|
682
|
-
synchronize_events do
|
|
683
|
-
task.state = :running
|
|
684
|
-
announce({ type: :plugin_init_started, bundle: task.bundle, id: task.id, label: task.label }) unless task.quiet
|
|
685
|
-
end
|
|
686
|
-
Log.info(:plugins, "init_started", bundle: task.bundle, id: task.id, label: task.label)
|
|
687
|
-
summary = task.block.call(task)
|
|
688
|
-
finish_init_task(task, ok: true, summary: summary.is_a?(String) ? summary : nil)
|
|
689
|
-
rescue StandardError => e
|
|
690
|
-
finish_init_task(task, ok: false, error: e.message)
|
|
691
|
-
end
|
|
692
|
-
private :run_init_task
|
|
693
|
-
|
|
694
|
-
def finish_init_task(task, ok:, summary: nil, error: nil)
|
|
695
|
-
Log.public_send(ok ? :info : :warn, :plugins, "init_finished", bundle: task.bundle, id: task.id, ok: ok,
|
|
696
|
-
ms: ((monotonic_now - task.started_at) * 1000).round,
|
|
697
|
-
msg: error)
|
|
698
|
-
shut_down = @lifecycle_mutex.synchronize { @shut_down }
|
|
699
|
-
synchronize_events do
|
|
700
|
-
task.state = ok ? :done : :failed
|
|
701
|
-
next if shut_down || (task.quiet && ok)
|
|
702
|
-
|
|
703
|
-
unless task.quiet
|
|
704
|
-
announce({ type: :plugin_init_finished, bundle: task.bundle, id: task.id, label: task.label, ok: ok,
|
|
705
|
-
summary: summary, error: error }.compact)
|
|
706
|
-
end
|
|
707
|
-
# A failure stays on screen (and for a UI that joins later) as a
|
|
708
|
-
# card: a short title (the card shows the bundle beside it), the
|
|
709
|
-
# detail in the body.
|
|
710
|
-
unless ok
|
|
711
|
-
title = task.failed || "setup failed"
|
|
712
|
-
body = task.failed ? error.to_s : "#{task.label}: #{error}"
|
|
713
|
-
show_card(source: task.bundle, title: title, body: body, level: :warn, id: "init-#{task.id}")
|
|
714
|
-
end
|
|
715
|
-
end
|
|
716
|
-
end
|
|
717
|
-
private :finish_init_task
|
|
718
|
-
|
|
719
638
|
# ── Load events ───────────────────────────────────────────────────────
|
|
720
639
|
|
|
721
640
|
# Announce what failed to load and what plugins showed while loading,
|
|
@@ -755,8 +674,7 @@ module Samagotchi
|
|
|
755
674
|
|
|
756
675
|
@shut_down = true
|
|
757
676
|
# An init task's requests end (a server's boot), so it finishes.
|
|
758
|
-
@
|
|
759
|
-
@anytime_threads.dup + @init_tasks.filter_map(&:thread)
|
|
677
|
+
@anytime_threads.dup + @plugin_tasks.shut_down!
|
|
760
678
|
end
|
|
761
679
|
# #stop_idle's work (the scheduler's stop is idempotent), where the
|
|
762
680
|
# callers haven't stopped it already.
|
|
@@ -769,7 +687,7 @@ module Samagotchi
|
|
|
769
687
|
end
|
|
770
688
|
left = threads.count(&:alive?)
|
|
771
689
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
772
|
-
@
|
|
690
|
+
@plugin_tasks.kill_leftovers
|
|
773
691
|
@services.stop_all
|
|
774
692
|
self
|
|
775
693
|
end
|
|
@@ -1035,7 +953,7 @@ module Samagotchi
|
|
|
1035
953
|
last_prompt: @session&.last_prompt,
|
|
1036
954
|
event_seq: @session_observer&.event_count,
|
|
1037
955
|
metrics: metrics,
|
|
1038
|
-
pending_question: @
|
|
956
|
+
pending_question: @question_desk.pending,
|
|
1039
957
|
used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
|
|
1040
958
|
preloaded_memory_names: preloaded_memory_names,
|
|
1041
959
|
muted_memory_names: @muted_memory_names.dup,
|
|
@@ -1144,14 +1062,11 @@ module Samagotchi
|
|
|
1144
1062
|
# default, so a bare Engine denies instead of waiting for nobody). Set
|
|
1145
1063
|
# by the host (TerminalUI, Worker).
|
|
1146
1064
|
def interface
|
|
1147
|
-
@interface
|
|
1065
|
+
@guardrail_wiring.interface
|
|
1148
1066
|
end
|
|
1149
1067
|
|
|
1150
1068
|
def interface=(value)
|
|
1151
|
-
|
|
1152
|
-
raise ArgumentError, "unknown interface #{value}" unless Guardrails::Context::INTERFACES.include?(value)
|
|
1153
|
-
|
|
1154
|
-
@interface = value
|
|
1069
|
+
@guardrail_wiring.interface = value
|
|
1155
1070
|
end
|
|
1156
1071
|
|
|
1157
1072
|
# Where the approval store lives: beside Session's state dir
|
|
@@ -1178,128 +1093,36 @@ module Samagotchi
|
|
|
1178
1093
|
def guardrail_state_dir=(state_dir)
|
|
1179
1094
|
# Also where list_sessions and send_note look for other sessions.
|
|
1180
1095
|
@state_dir = state_dir
|
|
1181
|
-
@
|
|
1182
|
-
@guardrail_protected = nil
|
|
1096
|
+
@guardrail_wiring.state_dir = state_dir
|
|
1183
1097
|
end
|
|
1184
1098
|
|
|
1185
|
-
# The gate's core checks, in order.
|
|
1099
|
+
# The gate's core checks, in order (GuardrailWiring#checks).
|
|
1186
1100
|
def guardrail_checks
|
|
1187
|
-
|
|
1188
|
-
[@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
|
|
1189
|
-
rules].compact
|
|
1101
|
+
@guardrail_wiring.checks
|
|
1190
1102
|
end
|
|
1191
1103
|
|
|
1192
|
-
# The YAML rules
|
|
1193
|
-
# installed bundles'. One that doesn't parse is a required load failure
|
|
1194
|
-
# (every call is denied). Read again when one of those files changed
|
|
1195
|
-
# (a stat of each per tool call), so a long-lived worker follows edits.
|
|
1104
|
+
# The YAML rules, read again when a file changed (GuardrailWiring#rules).
|
|
1196
1105
|
# @return [Guardrails::Rules]
|
|
1197
1106
|
def guardrail_rules
|
|
1198
|
-
@
|
|
1199
|
-
stamp = guardrail_rules_stamp
|
|
1200
|
-
if @guardrail_rules.nil? || stamp != @guardrail_rules_stamp
|
|
1201
|
-
@guardrail_failures.drop(:rules)
|
|
1202
|
-
@guardrail_rules = load_guardrail_rules
|
|
1203
|
-
@guardrail_rules_stamp = stamp
|
|
1204
|
-
end
|
|
1205
|
-
@guardrail_rules
|
|
1206
|
-
end
|
|
1207
|
-
end
|
|
1208
|
-
|
|
1209
|
-
# [path, mtime, size] of config.yml and every installed bundle's
|
|
1210
|
-
# manifest.json and guardrails/ file.
|
|
1211
|
-
def guardrail_rules_stamp
|
|
1212
|
-
require_relative "memory_bundle/provenance"
|
|
1213
|
-
paths = [Samagotchi::ConfigFile.global_path] +
|
|
1214
|
-
Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
|
|
1215
|
-
paths.compact.sort.map do |path|
|
|
1216
|
-
stat = File.stat(path)
|
|
1217
|
-
[path, stat.mtime.to_r, stat.size]
|
|
1218
|
-
rescue SystemCallError
|
|
1219
|
-
[path]
|
|
1220
|
-
end
|
|
1221
|
-
end
|
|
1222
|
-
|
|
1223
|
-
def load_guardrail_rules
|
|
1224
|
-
section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
1225
|
-
section = section["guardrails"] if section.is_a?(Hash)
|
|
1226
|
-
rules = []
|
|
1227
|
-
disable = []
|
|
1228
|
-
begin
|
|
1229
|
-
raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
|
|
1230
|
-
|
|
1231
|
-
rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
|
|
1232
|
-
disable = Guardrails::Rules.parse_disable(section && section["disable"])
|
|
1233
|
-
rescue Guardrails::Rules::ParseError => e
|
|
1234
|
-
Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
|
|
1235
|
-
@guardrail_failures.add("rules in config.yml", e.message, required: true, group: :rules)
|
|
1236
|
-
end
|
|
1237
|
-
Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
|
|
1238
|
-
enabled: Samagotchi::Config.get("guardrails.enabled") != false)
|
|
1107
|
+
@guardrail_wiring.rules
|
|
1239
1108
|
end
|
|
1240
|
-
private :guardrail_rules_stamp, :load_guardrail_rules
|
|
1241
1109
|
|
|
1242
|
-
# Installed bundles' guardrails/*.yml
|
|
1243
|
-
# A file that is missing, changed since install (sha256) or doesn't
|
|
1244
|
-
# parse is a required load failure.
|
|
1110
|
+
# Installed bundles' guardrails/*.yml (GuardrailWiring#bundle_rules).
|
|
1245
1111
|
def bundle_guardrail_rules
|
|
1246
|
-
|
|
1247
|
-
rules = []
|
|
1248
|
-
MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
|
|
1249
|
-
if data[:error]
|
|
1250
|
-
Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
|
|
1251
|
-
@guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
1252
|
-
next
|
|
1253
|
-
end
|
|
1254
|
-
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
1255
|
-
data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
|
|
1256
|
-
what = "rules #{basename} (bundle #{bundle_name})"
|
|
1257
|
-
path = File.join(dir, basename.to_s)
|
|
1258
|
-
begin
|
|
1259
|
-
raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
|
|
1260
|
-
|
|
1261
|
-
expected = (meta.is_a?(Hash) ? meta[:sha256] : nil).to_s.sub(/\Asha256:/, "")
|
|
1262
|
-
actual = Digest::SHA256.hexdigest(File.binread(path))
|
|
1263
|
-
if expected != actual
|
|
1264
|
-
raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
|
|
1265
|
-
end
|
|
1266
|
-
|
|
1267
|
-
doc = YAML.safe_load(File.read(path))
|
|
1268
|
-
raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
|
|
1269
|
-
|
|
1270
|
-
rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
1271
|
-
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
1272
|
-
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
1273
|
-
@guardrail_failures.add(what, e.message, required: true, group: :rules)
|
|
1274
|
-
end
|
|
1275
|
-
end
|
|
1276
|
-
end
|
|
1277
|
-
rules
|
|
1278
|
-
rescue StandardError => e
|
|
1279
|
-
Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
|
|
1280
|
-
@guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
|
|
1281
|
-
rules || []
|
|
1112
|
+
@guardrail_wiring.bundle_rules
|
|
1282
1113
|
end
|
|
1283
1114
|
|
|
1284
1115
|
# @return [Guardrails::LoadFailures]
|
|
1285
1116
|
attr_reader :guardrail_failures
|
|
1286
1117
|
|
|
1287
1118
|
def guardrail_protected_paths
|
|
1288
|
-
@
|
|
1289
|
-
require_relative "memory_bundle/provenance"
|
|
1290
|
-
config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
|
|
1291
|
-
hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
|
|
1292
|
-
Guardrails::ProtectedPaths.new(
|
|
1293
|
-
store_dir: File.dirname(@guardrail_approvals.path),
|
|
1294
|
-
bundles_dir: MemoryBundle::Provenance.bundles_dir,
|
|
1295
|
-
config_path: Samagotchi::ConfigFile.global_path,
|
|
1296
|
-
hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
|
|
1297
|
-
)
|
|
1298
|
-
end
|
|
1119
|
+
@guardrail_wiring.protected_paths
|
|
1299
1120
|
end
|
|
1300
1121
|
|
|
1301
1122
|
# @return [Guardrails::Approvals]
|
|
1302
|
-
|
|
1123
|
+
def guardrail_approvals
|
|
1124
|
+
@guardrail_wiring.approvals
|
|
1125
|
+
end
|
|
1303
1126
|
|
|
1304
1127
|
# @return [Guardrails::LoadFailures] the plugins that failed to load
|
|
1305
1128
|
attr_reader :plugin_failures
|
|
@@ -1334,37 +1157,16 @@ module Samagotchi
|
|
|
1334
1157
|
# The context the gate sees for a tool call now.
|
|
1335
1158
|
# @return [Guardrails::Context]
|
|
1336
1159
|
def guardrail_context
|
|
1337
|
-
|
|
1338
|
-
origin: @turn_origin, git: @guardrail_git)
|
|
1160
|
+
@guardrail_wiring.context
|
|
1339
1161
|
end
|
|
1340
1162
|
|
|
1341
|
-
# Ask the user to approve a call the gate voted `ask` on
|
|
1342
|
-
#
|
|
1343
|
-
# verdict: allow with the picked scope, or deny with a note for the
|
|
1344
|
-
# model. A --non-interactive run has no one to ask and denies at once.
|
|
1163
|
+
# Ask the user to approve a call the gate voted `ask` on
|
|
1164
|
+
# (GuardrailWiring#request_approval).
|
|
1345
1165
|
# @param verdict [Guardrails::Verdict]
|
|
1346
1166
|
# @return [Guardrails::Verdict]
|
|
1347
1167
|
def request_approval(verdict)
|
|
1348
|
-
|
|
1349
|
-
return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
|
|
1350
|
-
end
|
|
1351
|
-
|
|
1352
|
-
# A plugin tool is asked about by its label, as its row shows it.
|
|
1353
|
-
label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
|
|
1354
|
-
# verdict.call is the call that will run (a hook may have replaced it).
|
|
1355
|
-
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
|
|
1356
|
-
Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
|
|
1357
|
-
end
|
|
1358
|
-
|
|
1359
|
-
# The dry-run diff of an edit/write call for its approval; a preview
|
|
1360
|
-
# that fails only leaves the diff out, it never denies the call.
|
|
1361
|
-
def approval_preview(call)
|
|
1362
|
-
EditPreview.for(call)
|
|
1363
|
-
rescue StandardError => e
|
|
1364
|
-
Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
|
|
1365
|
-
nil
|
|
1168
|
+
@guardrail_wiring.request_approval(verdict)
|
|
1366
1169
|
end
|
|
1367
|
-
private :approval_preview
|
|
1368
1170
|
|
|
1369
1171
|
# The conversation as a hook may read it: a frozen array of copied
|
|
1370
1172
|
# messages, so a hook cannot change what the turn sends or stores.
|
|
@@ -1416,7 +1218,8 @@ module Samagotchi
|
|
|
1416
1218
|
notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
|
|
1417
1219
|
ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
|
|
1418
1220
|
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
|
|
1419
|
-
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
|
|
1221
|
+
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
|
|
1222
|
+
stop_generation: ->(reason:, hook:) { hook_stop_generation(reason, hook) }
|
|
1420
1223
|
)
|
|
1421
1224
|
end
|
|
1422
1225
|
private :hook_runtime
|
|
@@ -1462,22 +1265,7 @@ module Samagotchi
|
|
|
1462
1265
|
# answer, cancelled) is nil too.
|
|
1463
1266
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
1464
1267
|
def hook_ask_user(question, options, header, allow_freeform, hook)
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
opts = Tools::AskUserQuestion.normalize_options(options)
|
|
1468
|
-
unless opts
|
|
1469
|
-
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
|
|
1470
|
-
return nil
|
|
1471
|
-
end
|
|
1472
|
-
|
|
1473
|
-
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
1474
|
-
allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
|
|
1475
|
-
answer = open_question(fields)
|
|
1476
|
-
return nil unless answer.is_a?(Hash) && answer[:selected]
|
|
1477
|
-
|
|
1478
|
-
result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
|
|
1479
|
-
result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
|
|
1480
|
-
result
|
|
1268
|
+
@question_desk.ask_for_hook(question, options, header, allow_freeform, hook)
|
|
1481
1269
|
end
|
|
1482
1270
|
private :hook_ask_user
|
|
1483
1271
|
|
|
@@ -1494,216 +1282,58 @@ module Samagotchi
|
|
|
1494
1282
|
end
|
|
1495
1283
|
private :hook_stop_turn
|
|
1496
1284
|
|
|
1285
|
+
# Cut the streaming generation (reason :hook); the turn goes on and asks
|
|
1286
|
+
# the model again (the loops' cut path). No notice: the plugin posts its
|
|
1287
|
+
# own. The bundle and +reason+ go to the log and the retry's nudge.
|
|
1288
|
+
# @return [Boolean] true when a streaming generation was cut now
|
|
1289
|
+
def hook_stop_generation(reason, hook)
|
|
1290
|
+
ctrl = active_cancel_controller
|
|
1291
|
+
return false unless ctrl
|
|
1292
|
+
|
|
1293
|
+
ctrl.cancel_generation!(:hook, { by: hook_source(hook), reason: reason.to_s })
|
|
1294
|
+
end
|
|
1295
|
+
private :hook_stop_generation
|
|
1296
|
+
|
|
1497
1297
|
# ── Ask-user-question (structured qualification) ──────────────────────────
|
|
1498
1298
|
|
|
1499
1299
|
# @return [Hash, nil] current pending question (thread-safe copy)
|
|
1500
1300
|
def pending_question
|
|
1501
|
-
@
|
|
1301
|
+
@question_desk.pending
|
|
1502
1302
|
end
|
|
1503
1303
|
|
|
1504
1304
|
# Ask the model's question (ask_user_question): the kernel's
|
|
1505
1305
|
# question_handler, called on the turn thread with the payload
|
|
1506
|
-
# Tools::AskUserQuestion.validate made
|
|
1507
|
-
# returns the answer as JSON for the tool result.
|
|
1306
|
+
# Tools::AskUserQuestion.validate made (QuestionDesk#request).
|
|
1508
1307
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
1509
1308
|
# @return [String] normalized answer JSON
|
|
1510
1309
|
def request_question(payload)
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
1514
|
-
# tool failure the model learns to avoid the tool from.
|
|
1515
|
-
result = { dismissed: true, id: result[:id], note: QUESTION_DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
1516
|
-
result.is_a?(String) ? result : JSON.generate(result)
|
|
1517
|
-
end
|
|
1518
|
-
|
|
1519
|
-
QUESTION_DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
|
|
1520
|
-
"or ask in your reply if you can't."
|
|
1521
|
-
|
|
1522
|
-
# Open a question for the UIs and wait for its answer. Emits
|
|
1523
|
-
# :question_requested, persists it to the session, and BLOCKS until
|
|
1524
|
-
# answer_question / cancel_question wakes it (or the turn is cancelled).
|
|
1525
|
-
# The fields go to pending_question as given (no cleaning), extra keys
|
|
1526
|
-
# included, so a caller can add its own (kind:, approval:).
|
|
1527
|
-
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
1528
|
-
# @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
|
|
1529
|
-
# or {error:, …}; a String when a sync handler returned text itself
|
|
1530
|
-
def open_question(fields)
|
|
1531
|
-
id = SecureRandom.uuid
|
|
1532
|
-
pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
|
|
1533
|
-
|
|
1534
|
-
@question_mutex.synchronize do
|
|
1535
|
-
@pending_question = pending
|
|
1536
|
-
@question_answer = nil
|
|
1537
|
-
end
|
|
1538
|
-
# Persist to session file for WEB stub + resume (generic for all UIs)
|
|
1539
|
-
if @session
|
|
1540
|
-
@session.pending_question = pending.dup
|
|
1541
|
-
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1542
|
-
end
|
|
1543
|
-
# Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
|
|
1544
|
-
# stash this event (e.g. TerminalUI handle_question_event) will
|
|
1545
|
-
# discard it as stale if the synchronous handler below already answers
|
|
1546
|
-
# and clears pending — see drain_pending_question? staleness check.
|
|
1547
|
-
emit_event(nil, { type: :question_requested, pending_question: pending })
|
|
1548
|
-
|
|
1549
|
-
# If a synchronous UI handler is registered (TUI), invoke it inline on the
|
|
1550
|
-
# SAME thread that called request_question (TerminalUI's REPL thread is the
|
|
1551
|
-
# turn thread — no second thread exists to answer). This avoids deadlock.
|
|
1552
|
-
# This path is TUI-specific but the surrounding emit/clear is generic, so
|
|
1553
|
-
# any future UI that registers a sync handler gets the same guarantee.
|
|
1554
|
-
if instance_variable_defined?(:@question_sync_handler) && @question_sync_handler
|
|
1555
|
-
begin
|
|
1556
|
-
sync_res = @question_sync_handler.call(pending.dup)
|
|
1557
|
-
# Handler may have called answer_question or returned a hash/string
|
|
1558
|
-
@question_mutex.synchronize do
|
|
1559
|
-
if @question_answer
|
|
1560
|
-
ans = @question_answer
|
|
1561
|
-
@pending_question = nil
|
|
1562
|
-
if @session
|
|
1563
|
-
@session.pending_question = nil
|
|
1564
|
-
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1565
|
-
end
|
|
1566
|
-
emit_event(nil, { type: :question_answered, id: id, answer: ans })
|
|
1567
|
-
return ans
|
|
1568
|
-
end
|
|
1569
|
-
if sync_res.is_a?(Hash) && sync_res[:selected]
|
|
1570
|
-
# Treat returned hash as answer (handler rendered and parsed)
|
|
1571
|
-
@question_answer = sync_res
|
|
1572
|
-
@pending_question = nil
|
|
1573
|
-
if @session
|
|
1574
|
-
@session.pending_question = nil
|
|
1575
|
-
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1576
|
-
end
|
|
1577
|
-
emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
|
|
1578
|
-
return sync_res
|
|
1579
|
-
elsif sync_res.is_a?(String) && !sync_res.strip.empty?
|
|
1580
|
-
return sync_res
|
|
1581
|
-
end
|
|
1582
|
-
end
|
|
1583
|
-
rescue StandardError => e
|
|
1584
|
-
Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
|
|
1585
|
-
end
|
|
1586
|
-
# Sync handler existed but did not produce an answer — do not deadlock on
|
|
1587
|
-
# CV (no cross-thread answerer exists for synchronous UIs). Clear pending
|
|
1588
|
-
# and return an error so the model can fallback to plain text. Generic
|
|
1589
|
-
# observers will discard the stale question_requested via staleness check.
|
|
1590
|
-
@question_mutex.synchronize { @pending_question = nil }
|
|
1591
|
-
if @session
|
|
1592
|
-
@session.pending_question = nil
|
|
1593
|
-
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1594
|
-
end
|
|
1595
|
-
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
1596
|
-
end
|
|
1597
|
-
|
|
1598
|
-
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
1599
|
-
answer = nil
|
|
1600
|
-
@question_mutex.synchronize do
|
|
1601
|
-
loop do
|
|
1602
|
-
break if @question_answer
|
|
1603
|
-
break if active_cancel_controller&.cancelled?
|
|
1604
|
-
break if @pending_question.nil? || @pending_question[:status] != "pending"
|
|
1310
|
+
@question_desk.request(payload)
|
|
1311
|
+
end
|
|
1605
1312
|
|
|
1606
|
-
|
|
1607
|
-
@question_cv.wait(0.2)
|
|
1608
|
-
end
|
|
1609
|
-
answer = @question_answer
|
|
1610
|
-
# If cancelled
|
|
1611
|
-
if active_cancel_controller&.cancelled? && answer.nil?
|
|
1612
|
-
@pending_question = nil
|
|
1613
|
-
if @session
|
|
1614
|
-
@session.pending_question = nil
|
|
1615
|
-
begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1616
|
-
end
|
|
1617
|
-
emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
|
|
1618
|
-
return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
|
|
1619
|
-
end
|
|
1620
|
-
end
|
|
1313
|
+
QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
|
|
1621
1314
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
if answer
|
|
1629
|
-
emit_event(nil, { type: :question_answered, id: id, answer: answer })
|
|
1630
|
-
answer
|
|
1631
|
-
else
|
|
1632
|
-
{ error: "no answer", id: id }
|
|
1633
|
-
end
|
|
1315
|
+
# Open a question for the UIs and wait for its answer
|
|
1316
|
+
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
1317
|
+
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
1318
|
+
# @return [Hash, String] the answer, or {error:, …}
|
|
1319
|
+
def open_question(fields)
|
|
1320
|
+
@question_desk.open_question(fields)
|
|
1634
1321
|
end
|
|
1635
1322
|
|
|
1636
|
-
# Answer the pending question (called from UI thread).
|
|
1637
|
-
# @param id [String] pending id
|
|
1638
|
-
# @param selected [Array<String>] values/labels
|
|
1639
|
-
# @param freeform [String, nil]
|
|
1323
|
+
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1640
1324
|
# @return [Hash] normalized answer
|
|
1641
1325
|
def answer_question(id:, selected:, freeform: nil)
|
|
1642
|
-
|
|
1643
|
-
fm = freeform.to_s.strip
|
|
1644
|
-
fm = nil if fm.empty?
|
|
1645
|
-
@question_mutex.synchronize do
|
|
1646
|
-
pending = @pending_question
|
|
1647
|
-
raise QuestionNotPending, "no pending question" unless pending
|
|
1648
|
-
raise QuestionNotPending, "id mismatch" unless pending[:id].to_s == id.to_s
|
|
1649
|
-
# First responder wins: after the first answer the turn thread clears
|
|
1650
|
-
# @pending_question in a later lock block, so a second UI's answer can
|
|
1651
|
-
# land in between and must not overwrite the first.
|
|
1652
|
-
raise QuestionNotPending, "question already answered" if @question_answer
|
|
1653
|
-
raise QuestionNotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
|
|
1654
|
-
|
|
1655
|
-
opts = Array(pending[:options])
|
|
1656
|
-
# Validate selected subset of options (value == label in v1)
|
|
1657
|
-
invalid = sel.reject { |v| opts.include?(v) }
|
|
1658
|
-
unless invalid.empty?
|
|
1659
|
-
raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
|
|
1660
|
-
end
|
|
1661
|
-
if !pending[:multi_select] && sel.size > 1
|
|
1662
|
-
raise ArgumentError, "single-select question: got #{sel.size} selections"
|
|
1663
|
-
end
|
|
1664
|
-
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
1665
|
-
raise ArgumentError, "selection required"
|
|
1666
|
-
end
|
|
1667
|
-
# Persist pending cleared elsewhere; just set answer
|
|
1668
|
-
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
1669
|
-
# Derive indices for convenience
|
|
1670
|
-
answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
|
|
1671
|
-
@question_answer = answer
|
|
1672
|
-
@question_cv.broadcast
|
|
1673
|
-
answer
|
|
1674
|
-
end.tap do
|
|
1675
|
-
# A human answered: the session is back in the lists (ArchiveStore).
|
|
1676
|
-
ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
|
|
1677
|
-
end
|
|
1326
|
+
@question_desk.answer(id: id, selected: selected, freeform: freeform)
|
|
1678
1327
|
end
|
|
1679
1328
|
|
|
1680
1329
|
def set_question_sync_handler(&block)
|
|
1681
|
-
@
|
|
1330
|
+
@question_desk.sync_handler = block
|
|
1682
1331
|
end
|
|
1683
1332
|
|
|
1684
|
-
# Cancel the pending question (
|
|
1685
|
-
#
|
|
1686
|
-
# announce. A question already answered (the turn thread hasn't taken the
|
|
1687
|
-
# answer yet) or already closed stays as it is: the first responder wins.
|
|
1688
|
-
# @param id [String, nil] cancel only this question (a UI's dismiss
|
|
1689
|
-
# names the one it showed)
|
|
1690
|
-
# @return [Boolean] whether it was cancelled (true with none pending and
|
|
1691
|
-
# no id, as before)
|
|
1333
|
+
# Cancel the pending question (QuestionDesk#cancel).
|
|
1334
|
+
# @return [Boolean] whether it was cancelled
|
|
1692
1335
|
def cancel_question(reason = "user", id: nil)
|
|
1693
|
-
|
|
1694
|
-
pending = @pending_question
|
|
1695
|
-
next unless pending
|
|
1696
|
-
next if id && pending[:id].to_s != id.to_s
|
|
1697
|
-
next if @question_answer || pending[:status].to_s != "pending"
|
|
1698
|
-
|
|
1699
|
-
pending[:status] = "cancelled"
|
|
1700
|
-
@question_cv.broadcast
|
|
1701
|
-
pending[:id]
|
|
1702
|
-
end
|
|
1703
|
-
return id.nil? && pending_question.nil? unless cancelled_id
|
|
1704
|
-
|
|
1705
|
-
emit_event(nil, { type: :question_cancelled, id: cancelled_id, reason: reason.to_s }) rescue nil
|
|
1706
|
-
true
|
|
1336
|
+
@question_desk.cancel(reason, id: id)
|
|
1707
1337
|
end
|
|
1708
1338
|
|
|
1709
1339
|
# The system prompt for a target's loop (default: the effective model's):
|
|
@@ -1789,7 +1419,7 @@ module Samagotchi
|
|
|
1789
1419
|
|
|
1790
1420
|
messages = @activity_mutex.synchronize { @session&.messages } || []
|
|
1791
1421
|
covered = state[:covered].to_i
|
|
1792
|
-
turns_since = Array(messages).drop(covered).count { |m| Steer.
|
|
1422
|
+
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
|
|
1793
1423
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
1794
1424
|
end
|
|
1795
1425
|
|
|
@@ -1871,8 +1501,7 @@ module Samagotchi
|
|
|
1871
1501
|
@turn_event_sink = on_event
|
|
1872
1502
|
end
|
|
1873
1503
|
# The gate's context: who queued this turn, and git asked afresh.
|
|
1874
|
-
@
|
|
1875
|
-
@guardrail_git = Guardrails::GitInfo.new
|
|
1504
|
+
@guardrail_wiring.begin_turn(origin)
|
|
1876
1505
|
|
|
1877
1506
|
prompt = nil if continue
|
|
1878
1507
|
# For the cancel note: how long the turn ran.
|
|
@@ -1962,7 +1591,7 @@ module Samagotchi
|
|
|
1962
1591
|
result = backend.complete(
|
|
1963
1592
|
messages: messages,
|
|
1964
1593
|
max_iterations: max_iterations,
|
|
1965
|
-
on_stream_event: build_stream_event_handler(on_event),
|
|
1594
|
+
on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
|
|
1966
1595
|
cancel_controller: effective_controller,
|
|
1967
1596
|
model_name: bare_for_backend,
|
|
1968
1597
|
max_tool_output_chars: max_tool_output_chars,
|
|
@@ -2385,41 +2014,17 @@ module Samagotchi
|
|
|
2385
2014
|
end
|
|
2386
2015
|
|
|
2387
2016
|
# Keep a plugin's new tool set (chi.replace_tools, from any thread)
|
|
2388
|
-
# for the turn thread
|
|
2389
|
-
# registry is read only there. A later set of the same bundle wins.
|
|
2017
|
+
# for the turn thread (PluginTasks#stage_tools).
|
|
2390
2018
|
def stage_tools(bundle, specs, context)
|
|
2391
|
-
@
|
|
2392
|
-
return if @shut_down
|
|
2393
|
-
|
|
2394
|
-
@staged_tools[bundle] = [specs, context]
|
|
2395
|
-
end
|
|
2396
|
-
nil
|
|
2019
|
+
@plugin_tasks.stage_tools(bundle, specs, context)
|
|
2397
2020
|
end
|
|
2398
2021
|
private :stage_tools
|
|
2399
2022
|
|
|
2400
|
-
# Apply the staged tool sets
|
|
2401
|
-
#
|
|
2402
|
-
# a set changed anything. A name another source has is left out with a
|
|
2403
|
-
# notice.
|
|
2023
|
+
# Apply the staged tool sets, on the turn thread, before the turn's
|
|
2024
|
+
# system prompt is built (PluginTasks#apply_staged_tools!).
|
|
2404
2025
|
# @return [Boolean] whether the tools changed
|
|
2405
2026
|
def apply_staged_tools!
|
|
2406
|
-
|
|
2407
|
-
taken = @staged_tools
|
|
2408
|
-
@staged_tools = {}
|
|
2409
|
-
taken
|
|
2410
|
-
end
|
|
2411
|
-
changed = false
|
|
2412
|
-
staged.each do |bundle, (specs, context)|
|
|
2413
|
-
result = Plugin::Api.apply_tools(@tools, bundle, specs, context)
|
|
2414
|
-
changed ||= result[:changed]
|
|
2415
|
-
result[:skipped].each do |why|
|
|
2416
|
-
Log.warn(:plugins, "plugin_tool_skipped", bundle: bundle, msg: why)
|
|
2417
|
-
hook_notify("#{why}; left out", :warn, bundle)
|
|
2418
|
-
end
|
|
2419
|
-
Log.info(:plugins, "plugin_tools_replaced", bundle: bundle, tools: specs.size) if result[:changed]
|
|
2420
|
-
end
|
|
2421
|
-
tools_changed! if changed
|
|
2422
|
-
changed
|
|
2027
|
+
@plugin_tasks.apply_staged_tools!
|
|
2423
2028
|
end
|
|
2424
2029
|
public :apply_staged_tools!
|
|
2425
2030
|
|
|
@@ -2450,6 +2055,7 @@ module Samagotchi
|
|
|
2450
2055
|
card: ->(**card) { show_card(**card) },
|
|
2451
2056
|
steer: ->(text, source) { steer(text, source: source) },
|
|
2452
2057
|
stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
|
|
2058
|
+
stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
|
|
2453
2059
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2454
2060
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2455
2061
|
},
|
|
@@ -2516,74 +2122,6 @@ module Samagotchi
|
|
|
2516
2122
|
end
|
|
2517
2123
|
private :read_bundle_settings
|
|
2518
2124
|
|
|
2519
|
-
# Build (or disable) the idle recap job. On by default: with no recap
|
|
2520
|
-
# host or model configured it asks the session's current model on its
|
|
2521
|
-
# host, resolved at each attempt the way a turn does (a /model switch
|
|
2522
|
-
# counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
|
|
2523
|
-
# model:}` pins it; an incomplete one warns and leaves recap off.
|
|
2524
|
-
#
|
|
2525
|
-
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
2526
|
-
# (CLI > ENV > file > default). An explicit disable (`recap: false` as
|
|
2527
|
-
# the kwarg or in the config file, `recap: {enabled: false}`, or
|
|
2528
|
-
# SAMAGOTCHI_RECAP_ENABLED=false) always wins.
|
|
2529
|
-
def build_recap(recap)
|
|
2530
|
-
return nil if recap == false
|
|
2531
|
-
# The TUI passes the config file's section; a worker passes nothing, so
|
|
2532
|
-
# read it here too (a scalar `recap: false` is only seen this way).
|
|
2533
|
-
return nil if recap.nil? && ConfigFile.recap_config == false
|
|
2534
|
-
return nil if Samagotchi::Config.get("recap.enabled") == false
|
|
2535
|
-
|
|
2536
|
-
# Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
|
|
2537
|
-
kwarg_config = recap.is_a?(Hash) ? recap : {}
|
|
2538
|
-
|
|
2539
|
-
base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
|
|
2540
|
-
host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
|
|
2541
|
-
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
2542
|
-
label = model
|
|
2543
|
-
|
|
2544
|
-
target = nil
|
|
2545
|
-
if base_url.nil? && host_ref.nil? && model.nil?
|
|
2546
|
-
target = -> { session_model_recap_target }
|
|
2547
|
-
else
|
|
2548
|
-
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
2549
|
-
# API key variable from the host_registry entry
|
|
2550
|
-
api_key_env = nil
|
|
2551
|
-
if host_ref && !host_ref.empty?
|
|
2552
|
-
entry = @host_registry.find_entry(host_ref)
|
|
2553
|
-
if entry
|
|
2554
|
-
base_url = entry.openai_base_url
|
|
2555
|
-
api_key_env = entry.api_key_env
|
|
2556
|
-
# If model is host-qualified, extract bare model for recap client
|
|
2557
|
-
_, bare = @host_registry.parse_qualified_model(model) if model
|
|
2558
|
-
model = bare if bare && !bare.empty?
|
|
2559
|
-
else
|
|
2560
|
-
Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
|
|
2561
|
-
return nil
|
|
2562
|
-
end
|
|
2563
|
-
end
|
|
2564
|
-
|
|
2565
|
-
if base_url.to_s.strip.empty? || model.to_s.strip.empty?
|
|
2566
|
-
Log.warn(:recap, "recap_unconfigured",
|
|
2567
|
-
echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
|
|
2568
|
-
"Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
|
|
2569
|
-
"or leave them all out to recap with the session's own model.")
|
|
2570
|
-
return nil
|
|
2571
|
-
end
|
|
2572
|
-
fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
|
|
2573
|
-
target = -> { fixed }
|
|
2574
|
-
end
|
|
2575
|
-
|
|
2576
|
-
IdleRecap.new(
|
|
2577
|
-
engine: self,
|
|
2578
|
-
target: target,
|
|
2579
|
-
inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
|
|
2580
|
-
min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
|
|
2581
|
-
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
2582
|
-
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
2583
|
-
store: RecapStore.new(session_id_lookup: -> { @session&.id }, state_dir_lookup: -> { session_state_dir })
|
|
2584
|
-
)
|
|
2585
|
-
end
|
|
2586
|
-
|
|
2587
2125
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2588
2126
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2589
2127
|
# and bare model name, as a turn resolves them.
|
|
@@ -2593,37 +2131,6 @@ module Samagotchi
|
|
|
2593
2131
|
model: target.bare_model, label: @effective_model_name.to_s }
|
|
2594
2132
|
end
|
|
2595
2133
|
|
|
2596
|
-
# recap.sentences as [min, max]; an invalid value warns and falls back to
|
|
2597
|
-
# the default range (the recap stays on).
|
|
2598
|
-
def recap_sentences(value)
|
|
2599
|
-
range = IdleRecap::RecapPrompt.sentences_range(value)
|
|
2600
|
-
return range if range
|
|
2601
|
-
|
|
2602
|
-
default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
|
|
2603
|
-
Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join('-')}",
|
|
2604
|
-
value: value.to_s)
|
|
2605
|
-
default
|
|
2606
|
-
end
|
|
2607
|
-
|
|
2608
|
-
# Read a scalar recap setting via the Config registry (ENV > file > default).
|
|
2609
|
-
def registry_string(key)
|
|
2610
|
-
value = Samagotchi::Config.get(key)
|
|
2611
|
-
value = value.to_s.strip
|
|
2612
|
-
value.empty? ? nil : value
|
|
2613
|
-
rescue StandardError
|
|
2614
|
-
nil
|
|
2615
|
-
end
|
|
2616
|
-
|
|
2617
|
-
# Resolve a numeric recap setting: kwarg > Config registry > built-in default.
|
|
2618
|
-
def recap_number_setting(kwarg_config, kwarg_key, config_key, default, numeric_type)
|
|
2619
|
-
value = kwarg_config[kwarg_key] || kwarg_config[kwarg_key.to_s]
|
|
2620
|
-
value = Samagotchi::Config.get(config_key) if value.nil? || value.to_s.strip.empty?
|
|
2621
|
-
value = default if value.nil? || value.to_s.strip.empty?
|
|
2622
|
-
numeric_type == :float ? value.to_f : value.to_i
|
|
2623
|
-
rescue StandardError
|
|
2624
|
-
numeric_type == :float ? default.to_f : default.to_i
|
|
2625
|
-
end
|
|
2626
|
-
|
|
2627
2134
|
# Build the idle reminders job. Always created (reminders are opt-in
|
|
2628
2135
|
# via the agent calling register_reminder). The shared IdleScheduler
|
|
2629
2136
|
# polls it and triggers synthetic turns when reminders are due.
|
|
@@ -2640,11 +2147,6 @@ module Samagotchi
|
|
|
2640
2147
|
)
|
|
2641
2148
|
end
|
|
2642
2149
|
|
|
2643
|
-
def string_config(config, key)
|
|
2644
|
-
value = config[key]
|
|
2645
|
-
value.to_s.strip.empty? ? nil : value.to_s
|
|
2646
|
-
end
|
|
2647
|
-
|
|
2648
2150
|
# ── Event helpers ──────────────────────────────────────────────────────────
|
|
2649
2151
|
|
|
2650
2152
|
# Always return a handler so raw kernel loop events reach the persistent
|
|
@@ -2669,18 +2171,27 @@ module Samagotchi
|
|
|
2669
2171
|
# * Non-splitting profiles (nil think close, e.g. Gemma) leave the event
|
|
2670
2172
|
# unchanged; the web client then falls back to raw `content`, preserving
|
|
2671
2173
|
# today's behavior (no regression).
|
|
2672
|
-
|
|
2174
|
+
#
|
|
2175
|
+
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
2176
|
+
# each chunk's thinking and text after the UIs had it.
|
|
2177
|
+
def build_stream_event_handler(on_event, cancel_controller: nil)
|
|
2673
2178
|
splitter = ThoughtStreamSplitter.for_profile(profile)
|
|
2674
2179
|
enrich = profile.thought_close ? :always : :never
|
|
2180
|
+
watch = stream_watch(cancel_controller)
|
|
2675
2181
|
proc do |event|
|
|
2182
|
+
progress = nil
|
|
2676
2183
|
case event[:type]
|
|
2677
2184
|
when :generation_started
|
|
2678
2185
|
splitter = ThoughtStreamSplitter.for_profile(profile)
|
|
2186
|
+
watch&.started(event[:iteration])
|
|
2679
2187
|
when :generation_chunk
|
|
2680
2188
|
# The chat loop already splits its stream (reasoning arrives apart
|
|
2681
2189
|
# from the answer); only raw native chunks are split here.
|
|
2682
|
-
|
|
2190
|
+
if event.key?(:text)
|
|
2191
|
+
progress = { thinking: event[:thinking], text: event[:text] }
|
|
2192
|
+
else
|
|
2683
2193
|
delta = splitter.feed(event[:content])
|
|
2194
|
+
progress = { thinking: delta[:thinking], text: delta[:text] }
|
|
2684
2195
|
event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
|
|
2685
2196
|
end
|
|
2686
2197
|
end
|
|
@@ -2689,6 +2200,7 @@ module Samagotchi
|
|
|
2689
2200
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2690
2201
|
|
|
2691
2202
|
emit_event(on_event, event)
|
|
2203
|
+
watch.feed(**progress) if watch && progress
|
|
2692
2204
|
if event[:type] == :generation_completed
|
|
2693
2205
|
check_thinking_honoured(event)
|
|
2694
2206
|
announce_effort_ignored
|
|
@@ -2696,6 +2208,14 @@ module Samagotchi
|
|
|
2696
2208
|
end
|
|
2697
2209
|
end
|
|
2698
2210
|
|
|
2211
|
+
# The turn's StreamWatch, or nil when no hook listens.
|
|
2212
|
+
def stream_watch(cancel_controller)
|
|
2213
|
+
return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
|
|
2214
|
+
|
|
2215
|
+
Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
|
|
2216
|
+
end
|
|
2217
|
+
private :stream_watch
|
|
2218
|
+
|
|
2699
2219
|
def emit_event(on_event, event)
|
|
2700
2220
|
# Capture used memories synchronously in the turn thread.
|
|
2701
2221
|
read_names = begin
|