samagotchi 0.11.0 → 0.12.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 +29 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -4
- data/docs/cli.md +25 -1
- data/docs/desktop.md +24 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/engine.rb +445 -482
- data/lib/samagotchi/host_registry.rb +26 -2
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +2 -6
- data/lib/samagotchi/terminal_ui.rb +8 -28
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +14 -33
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +1 -1
- metadata +8 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "yaml"
|
|
|
8
8
|
|
|
9
9
|
require_relative "config"
|
|
10
10
|
require_relative "context_note"
|
|
11
|
+
require_relative "context_status"
|
|
11
12
|
require_relative "steer"
|
|
12
13
|
require_relative "turn_note"
|
|
13
14
|
require_relative "model_profile"
|
|
@@ -24,6 +25,7 @@ require_relative "client"
|
|
|
24
25
|
require_relative "host_registry"
|
|
25
26
|
require_relative "llm/backend"
|
|
26
27
|
require_relative "llm/openai_chat"
|
|
28
|
+
require_relative "llm/turn_settings"
|
|
27
29
|
require_relative "session"
|
|
28
30
|
require_relative "archive_store"
|
|
29
31
|
require_relative "session_observer"
|
|
@@ -38,8 +40,11 @@ require_relative "idle_scheduler"
|
|
|
38
40
|
require_relative "hooks"
|
|
39
41
|
require_relative "guardrails"
|
|
40
42
|
require_relative "reminder_store"
|
|
43
|
+
require_relative "reminder_queue"
|
|
41
44
|
require_relative "tools/memory"
|
|
42
45
|
require_relative "muted_memories"
|
|
46
|
+
require_relative "used_memories"
|
|
47
|
+
require_relative "turn_state"
|
|
43
48
|
require_relative "bundle_needs"
|
|
44
49
|
require_relative "model_overlay"
|
|
45
50
|
require_relative "served_model"
|
|
@@ -95,6 +100,10 @@ module Samagotchi
|
|
|
95
100
|
# delegate (a child would outlive it) nor plugin fork
|
|
96
101
|
def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
97
102
|
plugins: true, scratch: false, model_typed: nil)
|
|
103
|
+
# The turn as other threads see it (flag, cancel controller, sink,
|
|
104
|
+
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
105
|
+
# may steer or ask whether a turn runs while it loads.
|
|
106
|
+
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
98
107
|
@scratch = scratch
|
|
99
108
|
@no_interrupt = no_interrupt
|
|
100
109
|
@chat_backend = nil
|
|
@@ -189,47 +198,41 @@ module Samagotchi
|
|
|
189
198
|
@shut_down = false
|
|
190
199
|
load_plugins if plugins
|
|
191
200
|
guardrail_rules
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
@reminder_store = ReminderStore.new
|
|
200
|
-
end
|
|
201
|
+
# The Engine owns the reminder store; its kernel (built here, or a
|
|
202
|
+
# spec's) gets it, so tool calls via KernelLoop and reminder injection
|
|
203
|
+
# via Engine read/write the same store.
|
|
204
|
+
@reminder_store = ReminderStore.new
|
|
205
|
+
# The due reminders on their way into a turn, and the names the idle
|
|
206
|
+
# tick queued for a reminder turn.
|
|
207
|
+
@reminder_queue = ReminderQueue.new(store: @reminder_store)
|
|
201
208
|
# Build the idle reminders detector (wired to reminder_store)
|
|
202
209
|
callback = reminders.is_a?(Hash) && reminders[:callback] ? reminders[:callback] : nil
|
|
203
210
|
@reminders = build_reminders(auto_turn_callback: callback)
|
|
204
211
|
# Track whether this is the first turn in the session (for session_start event)
|
|
205
212
|
@first_turn = true
|
|
213
|
+
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
214
|
+
# the one built here does.
|
|
206
215
|
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
|
|
207
216
|
tools: @tools)
|
|
217
|
+
@kernel.reminder_store = @reminder_store
|
|
218
|
+
@kernel.hooks = @hooks
|
|
219
|
+
@kernel.tools = @tools
|
|
208
220
|
sync_kernel_client!
|
|
209
221
|
sync_model_key!
|
|
210
222
|
# The mutes never change during a session, so no re-sync: the kernel's
|
|
211
223
|
# memory_read guard reads the same list for every turn.
|
|
212
224
|
@muted_memory_names = MutedMemories.normalize_list(muted_memories)
|
|
213
|
-
@kernel.muted_memory_names = @muted_memory_names
|
|
225
|
+
@kernel.muted_memory_names = @muted_memory_names
|
|
214
226
|
# Keep kernel client in sync with active host via setter
|
|
215
227
|
@kernel_client_synced = false
|
|
216
|
-
# Engine owns hooks; if a kernel was supplied externally (TUI path) propagate
|
|
217
|
-
# the Engine's registry so all UIs reuse the same instance. Without this the
|
|
218
|
-
# TUI's KernelLoop fires with nil hooks and before_generation/after_generation
|
|
219
|
-
# etc. never fire in interactive mode.
|
|
220
|
-
if kernel && @kernel.respond_to?(:hooks=)
|
|
221
|
-
@kernel.hooks = @hooks
|
|
222
|
-
end
|
|
223
|
-
# Likewise its tools: the REPL builds its kernel before the Engine.
|
|
224
|
-
@kernel.tools = @tools if kernel && @kernel.respond_to?(:tools=)
|
|
225
228
|
# ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
|
|
226
|
-
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
229
|
+
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
227
230
|
# Every tool call asks this gate first. The kernel is never rebuilt, so
|
|
228
231
|
# it holds across model switches.
|
|
229
232
|
self.guardrail_state_dir = Session.default_state_dir
|
|
230
233
|
# list_sessions and send_note speak for whichever session runs now.
|
|
231
|
-
@kernel.peers = PeerView.new(self)
|
|
232
|
-
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
234
|
+
@kernel.peers = PeerView.new(self)
|
|
235
|
+
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
233
236
|
# What a hook can do beyond reading its event (event[:notify],
|
|
234
237
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
235
238
|
@hooks.runtime = hook_runtime
|
|
@@ -244,29 +247,18 @@ module Samagotchi
|
|
|
244
247
|
@session = nil
|
|
245
248
|
@session_observer = SessionObserver.new
|
|
246
249
|
@metrics = SessionMetrics.new
|
|
247
|
-
@
|
|
248
|
-
@used_memory_mutex = Monitor.new
|
|
250
|
+
@used_memories = UsedMemories.new
|
|
249
251
|
# Hydrate from resumed session if present
|
|
250
|
-
if @resume_session
|
|
251
|
-
@
|
|
252
|
+
if @resume_session
|
|
253
|
+
@used_memories.absorb(@resume_session)
|
|
252
254
|
@session = @resume_session
|
|
253
255
|
end
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
@
|
|
260
|
-
@last_activity_at = monotonic_now
|
|
261
|
-
@activity_seq = 0
|
|
262
|
-
@turn_running = false
|
|
263
|
-
@active_cancel_controller = nil
|
|
264
|
-
# Plugin steers ({text:, source:}) waiting for the running turn's next
|
|
265
|
-
# boundary (#steer); guarded by @activity_mutex.
|
|
266
|
-
@steers = []
|
|
267
|
-
# Reminder names the interactive REPL's IdleReminders callback marked due;
|
|
268
|
-
# the REPL polls them to decide when to run a synthetic reminder turn.
|
|
269
|
-
@due_reminder_names = []
|
|
256
|
+
# The idle subsystems' (session recap + reminders) inactivity clock
|
|
257
|
+
# starts now, after the plugins loaded. `record_activity` is the
|
|
258
|
+
# single seam every UI calls (run_turn itself, the REPL on keystrokes
|
|
259
|
+
# and after a reminder turn), so the idle layer's clock is identical
|
|
260
|
+
# across UIs.
|
|
261
|
+
@turn_state.restart_clock!
|
|
270
262
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
271
263
|
session_target: -> { session_model_recap_target },
|
|
272
264
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -296,37 +288,29 @@ module Samagotchi
|
|
|
296
288
|
backend_for(@host_registry.resolve(@effective_model_name))
|
|
297
289
|
end
|
|
298
290
|
|
|
299
|
-
# Record that activity happened (user input or a completed turn)
|
|
300
|
-
#
|
|
301
|
-
#
|
|
291
|
+
# Record that activity happened (user input or a completed turn): the
|
|
292
|
+
# idle jobs' seam (TurnState#record_activity). Each call advances both
|
|
293
|
+
# the last-activity timestamp and the activity sequence.
|
|
302
294
|
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
303
295
|
def record_activity(now = nil)
|
|
304
|
-
@
|
|
305
|
-
@last_activity_at = now ? now.to_f : monotonic_now
|
|
306
|
-
@activity_seq += 1
|
|
307
|
-
end
|
|
296
|
+
@turn_state.record_activity(now)
|
|
308
297
|
end
|
|
309
298
|
|
|
310
299
|
# @return [Float] monotonic seconds of the last recorded activity
|
|
311
300
|
def last_activity_at
|
|
312
|
-
@
|
|
301
|
+
@turn_state.last_activity_at
|
|
313
302
|
end
|
|
314
303
|
|
|
315
304
|
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
316
305
|
# #record_activity; lets the idle detector summarize once per idle window)
|
|
317
306
|
def activity_seq
|
|
318
|
-
@
|
|
307
|
+
@turn_state.activity_seq
|
|
319
308
|
end
|
|
320
309
|
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
def set_turn_running(running)
|
|
324
|
-
@activity_mutex.synchronize { @turn_running = running }
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# @return [Boolean] true while a turn is in flight
|
|
310
|
+
# @return [Boolean] true while a turn is in flight (the idle jobs never
|
|
311
|
+
# fire, nor render, while the model is generating)
|
|
328
312
|
def turn_running?
|
|
329
|
-
@
|
|
313
|
+
@turn_state.running?
|
|
330
314
|
end
|
|
331
315
|
|
|
332
316
|
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
@@ -337,15 +321,7 @@ module Samagotchi
|
|
|
337
321
|
# the turn ends first, it is dropped (logged).
|
|
338
322
|
# @return [Boolean] whether it was queued
|
|
339
323
|
def steer(text, source:)
|
|
340
|
-
text
|
|
341
|
-
return false if text.empty?
|
|
342
|
-
|
|
343
|
-
@activity_mutex.synchronize do
|
|
344
|
-
return false unless @turn_running
|
|
345
|
-
|
|
346
|
-
@steers << { text: text, source: source.to_s }
|
|
347
|
-
end
|
|
348
|
-
true
|
|
324
|
+
@turn_state.steer(text, source: source)
|
|
349
325
|
end
|
|
350
326
|
|
|
351
327
|
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
@@ -362,11 +338,7 @@ module Samagotchi
|
|
|
362
338
|
private :turn_drain
|
|
363
339
|
|
|
364
340
|
def take_steers(drop)
|
|
365
|
-
steers = @
|
|
366
|
-
taken = @steers
|
|
367
|
-
@steers = []
|
|
368
|
-
taken
|
|
369
|
-
end
|
|
341
|
+
steers = @turn_state.take_steers
|
|
370
342
|
return steers unless drop
|
|
371
343
|
|
|
372
344
|
log_dropped_steers(steers, "answered")
|
|
@@ -383,41 +355,37 @@ module Samagotchi
|
|
|
383
355
|
|
|
384
356
|
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
385
357
|
def due_reminder_names
|
|
386
|
-
@
|
|
358
|
+
@reminder_queue.pending_names
|
|
387
359
|
end
|
|
388
360
|
|
|
389
361
|
# Queue reminder names for a synthetic REPL turn (IdleReminders callback).
|
|
390
362
|
def note_due_reminders(names)
|
|
391
|
-
@
|
|
363
|
+
@reminder_queue.note_pending(names)
|
|
392
364
|
end
|
|
393
365
|
|
|
394
366
|
def clear_due_reminder_names!
|
|
395
|
-
@
|
|
367
|
+
@reminder_queue.clear_pending!
|
|
396
368
|
end
|
|
397
369
|
|
|
398
370
|
# @return [CancellationController, nil] active turn's cancellation controller
|
|
399
371
|
def active_cancel_controller
|
|
400
|
-
@
|
|
372
|
+
@turn_state.controller
|
|
401
373
|
end
|
|
402
374
|
|
|
403
375
|
# Cancel the currently running turn, if any.
|
|
404
376
|
# @param reason [Symbol] cancellation reason
|
|
405
377
|
# @return [Boolean] whether a cancellation was triggered
|
|
406
378
|
def cancel_current_turn!(reason = :manual)
|
|
407
|
-
|
|
408
|
-
return false unless ctrl
|
|
409
|
-
|
|
410
|
-
ctrl.cancel!(reason)
|
|
379
|
+
@turn_state.cancel!(reason)
|
|
411
380
|
end
|
|
412
381
|
|
|
413
382
|
# Snapshot the current session messages as a JSON string for the idle
|
|
414
|
-
# recap.
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
# session.messages.
|
|
383
|
+
# recap. The array is never mutated in place (a turn or a note replaces
|
|
384
|
+
# it), so the reference read here is a consistent snapshot; a dup'd
|
|
385
|
+
# copy is serialized. Never mutates session.messages.
|
|
418
386
|
# @return [String] JSON array of the messages
|
|
419
387
|
def messages_json_for_recap
|
|
420
|
-
snapshot = @
|
|
388
|
+
snapshot = @session&.messages
|
|
421
389
|
return "[]" if snapshot.nil?
|
|
422
390
|
|
|
423
391
|
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
@@ -453,7 +421,7 @@ module Samagotchi
|
|
|
453
421
|
def store_model!(session)
|
|
454
422
|
ref = effective_model_ref
|
|
455
423
|
session.model_name = ref
|
|
456
|
-
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
|
|
424
|
+
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
|
|
457
425
|
session
|
|
458
426
|
end
|
|
459
427
|
|
|
@@ -495,7 +463,7 @@ module Samagotchi
|
|
|
495
463
|
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
496
464
|
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
497
465
|
fallback = typed_key == @model_key ? nil : typed_key
|
|
498
|
-
@kernel.sync_model_key!(@model_key, fallback: fallback)
|
|
466
|
+
@kernel.sync_model_key!(@model_key, fallback: fallback)
|
|
499
467
|
end
|
|
500
468
|
private :sync_model_key!
|
|
501
469
|
|
|
@@ -511,7 +479,7 @@ module Samagotchi
|
|
|
511
479
|
def sync_kernel_client!
|
|
512
480
|
target = @host_registry.resolve(@effective_model_name)
|
|
513
481
|
@client = target.client
|
|
514
|
-
@kernel.client = target.client if @kernel.
|
|
482
|
+
@kernel.client = target.client if @kernel.client != target.client
|
|
515
483
|
backend_for(target)
|
|
516
484
|
end
|
|
517
485
|
|
|
@@ -609,11 +577,7 @@ module Samagotchi
|
|
|
609
577
|
# A plugin's init task (chi.init): never a running turn's event.
|
|
610
578
|
return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
|
|
611
579
|
|
|
612
|
-
sink =
|
|
613
|
-
in_turn = @activity_mutex.synchronize do
|
|
614
|
-
sink = @turn_event_sink
|
|
615
|
-
@turn_running
|
|
616
|
-
end
|
|
580
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
617
581
|
card[:in_turn] = in_turn ? true : false
|
|
618
582
|
if in_turn
|
|
619
583
|
emit_event(sink, card)
|
|
@@ -819,7 +783,7 @@ module Samagotchi
|
|
|
819
783
|
sync_model_key!
|
|
820
784
|
@prompt_builder.reset!
|
|
821
785
|
sync_kernel_client!
|
|
822
|
-
@client.invalidate_context_window!
|
|
786
|
+
@client.invalidate_context_window!
|
|
823
787
|
@metrics.forget_model_reports!
|
|
824
788
|
ref = effective_model_ref
|
|
825
789
|
if persist_default
|
|
@@ -858,53 +822,17 @@ module Samagotchi
|
|
|
858
822
|
end
|
|
859
823
|
|
|
860
824
|
# ── Reminders API ────────────────────────────────────────────────────────────
|
|
861
|
-
|
|
862
|
-
# Get and inject all due reminders into the conversation. Called at the top
|
|
863
|
-
# of run_turn (before set_turn_running(true)) so injection happens on the
|
|
864
|
-
# main thread, serially with the turn — no TOCTOU race.
|
|
865
|
-
#
|
|
866
|
-
# Returns the array of due reminder hashes (may be empty). Injects a
|
|
867
|
-
# [SYSTEM: REMINDERS DUE] message into the conversation when there are
|
|
868
|
-
# Get due reminders, inject them into the provided messages array as
|
|
869
|
-
# [SYSTEM: REMINDERS DUE], and atomically mark all as fired under one
|
|
870
|
-
# ReminderStore lock.
|
|
871
|
-
#
|
|
872
|
-
# This is the canonical method for reminder injection, called from
|
|
873
|
-
# Engine#run_turn after system prompt construction so the reminder text
|
|
874
|
-
# is never overwritten.
|
|
875
825
|
#
|
|
876
|
-
#
|
|
877
|
-
#
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
reminder_lines = due.map do |r|
|
|
883
|
-
" #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)"
|
|
884
|
-
end.join("\n")
|
|
885
|
-
reminder_text = "[SYSTEM: REMINDERS DUE]\n#{reminder_lines}\n[END REMINDERS]"
|
|
886
|
-
# Append as a tail message to preserve prefix KV cache. Mutating the
|
|
887
|
-
# head system prompt invalidates the cache for the entire conversation
|
|
888
|
-
# (prompt re-evaluated every interval). A tail append keeps the prefix
|
|
889
|
-
# intact — only the new reminder suffix is evaluated. Mirrors the
|
|
890
|
-
# context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
|
|
891
|
-
messages << { role: "system", content: reminder_text }
|
|
892
|
-
# Atomically mark all due as fired under one lock and clear the
|
|
893
|
-
# IdleReminders latch so the next interval can be detected.
|
|
894
|
-
@reminder_store&.mark_fired_batch(due.map { |r| r[:name] })
|
|
895
|
-
@reminders&.clear_due
|
|
896
|
-
# Also clear the REPL's due-reminder queue (#note_due_reminders).
|
|
897
|
-
# Without this, a normal-turn injection leaves a stale entry, causing
|
|
898
|
-
# the next top-of-loop synthetic turn to fire empty and duplicate output.
|
|
899
|
-
clear_due_reminder_names!
|
|
900
|
-
due
|
|
901
|
-
end
|
|
826
|
+
# Each turn injects the reminders due then (#turn_messages,
|
|
827
|
+
# ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
|
|
828
|
+
# finds them due and its callback queues their names
|
|
829
|
+
# (#note_due_reminders); the worker and the REPL run a reminder turn
|
|
830
|
+
# (a continue turn) for them.
|
|
902
831
|
|
|
903
832
|
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
904
|
-
# which
|
|
833
|
+
# which a turn would inject), regardless of the queued names
|
|
905
834
|
def reminders_due?
|
|
906
|
-
due
|
|
907
|
-
!(due.nil? || due.empty?)
|
|
835
|
+
@reminder_queue.due?
|
|
908
836
|
end
|
|
909
837
|
|
|
910
838
|
# @return [ReminderStore] the reminder store for inspection
|
|
@@ -941,9 +869,9 @@ module Samagotchi
|
|
|
941
869
|
# no turn yet (it woke after an idle exit): nil when either count is
|
|
942
870
|
# unknown or context.status is off.
|
|
943
871
|
def saved_context_status(context)
|
|
944
|
-
return nil unless context
|
|
872
|
+
return nil unless context
|
|
945
873
|
|
|
946
|
-
|
|
874
|
+
ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
947
875
|
end
|
|
948
876
|
|
|
949
877
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
@@ -1008,7 +936,7 @@ module Samagotchi
|
|
|
1008
936
|
event_seq: @session_observer&.event_count,
|
|
1009
937
|
metrics: metrics,
|
|
1010
938
|
pending_question: @question_desk.pending,
|
|
1011
|
-
used_memory_names: @
|
|
939
|
+
used_memory_names: @used_memories.names,
|
|
1012
940
|
preloaded_memory_names: preloaded_memory_names,
|
|
1013
941
|
muted_memory_names: @muted_memory_names.dup,
|
|
1014
942
|
parent_id: @session&.parent_id,
|
|
@@ -1024,7 +952,7 @@ module Samagotchi
|
|
|
1024
952
|
|
|
1025
953
|
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
1026
954
|
def used_memory_names
|
|
1027
|
-
@
|
|
955
|
+
@used_memories.names
|
|
1028
956
|
end
|
|
1029
957
|
|
|
1030
958
|
# @return [Array<String>] the memories hidden from this session (normalized names)
|
|
@@ -1043,73 +971,6 @@ module Samagotchi
|
|
|
1043
971
|
MutedMemories.muted?(name, @muted_memory_names)
|
|
1044
972
|
end
|
|
1045
973
|
|
|
1046
|
-
def add_used_memory_names(names)
|
|
1047
|
-
return if names.nil? || Array(names).empty?
|
|
1048
|
-
|
|
1049
|
-
@used_memory_mutex.synchronize do
|
|
1050
|
-
Array(names).each do |n|
|
|
1051
|
-
v = n.to_s.strip
|
|
1052
|
-
next if v.empty?
|
|
1053
|
-
next if @used_memory_names.include?(v)
|
|
1054
|
-
|
|
1055
|
-
@used_memory_names << v
|
|
1056
|
-
end
|
|
1057
|
-
end
|
|
1058
|
-
end
|
|
1059
|
-
|
|
1060
|
-
def sync_used_memories_from_session(session)
|
|
1061
|
-
return unless session && session.respond_to?(:used_memory_names)
|
|
1062
|
-
|
|
1063
|
-
add_used_memory_names(Array(session.used_memory_names))
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
def memory_name_from_tool_call(call)
|
|
1067
|
-
return nil unless call.is_a?(Hash)
|
|
1068
|
-
|
|
1069
|
-
tool = call[:name].to_s
|
|
1070
|
-
case tool
|
|
1071
|
-
when Tools::MemoryRead::NAME
|
|
1072
|
-
content = call[:content].to_s.strip
|
|
1073
|
-
return nil if content.empty?
|
|
1074
|
-
|
|
1075
|
-
# comma-separated names
|
|
1076
|
-
content.split(",").map { |s| normalize_memory_name(s) }.compact
|
|
1077
|
-
when Tools::Read::NAME
|
|
1078
|
-
path = call[:content].to_s.strip.tr("\\", "/")
|
|
1079
|
-
return nil if path.empty?
|
|
1080
|
-
return nil unless path.match?(/memories\/.+\.md\z/)
|
|
1081
|
-
|
|
1082
|
-
normalize_memory_name(path)
|
|
1083
|
-
else
|
|
1084
|
-
nil
|
|
1085
|
-
end
|
|
1086
|
-
end
|
|
1087
|
-
|
|
1088
|
-
def normalize_memory_name(raw)
|
|
1089
|
-
v = raw.to_s.strip
|
|
1090
|
-
return nil if v.empty?
|
|
1091
|
-
|
|
1092
|
-
# basename without .md, handle comma already split
|
|
1093
|
-
base = File.basename(v, ".md").strip
|
|
1094
|
-
base.empty? ? nil : base
|
|
1095
|
-
end
|
|
1096
|
-
|
|
1097
|
-
# A memory read (memory_read, or read of a memories/*.md file) as it
|
|
1098
|
-
# starts: its names join used_memory_names.
|
|
1099
|
-
# @return [Array<String>, nil] the names this call read, nil for any other event
|
|
1100
|
-
def capture_used_memory_from_event(event)
|
|
1101
|
-
return unless event.is_a?(Hash) && event[:type] == :tool_call_started
|
|
1102
|
-
|
|
1103
|
-
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1104
|
-
names = memory_name_from_tool_call(call)
|
|
1105
|
-
# A refused read of a muted memory is not a use of it.
|
|
1106
|
-
names = Array(names).reject { |n| memory_muted?(n) }
|
|
1107
|
-
return if names.empty?
|
|
1108
|
-
|
|
1109
|
-
add_used_memory_names(names)
|
|
1110
|
-
names
|
|
1111
|
-
end
|
|
1112
|
-
|
|
1113
974
|
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
1114
975
|
|
|
1115
976
|
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
@@ -1132,7 +993,7 @@ module Samagotchi
|
|
|
1132
993
|
|
|
1133
994
|
# The metrics load the session's saved records from its state dir, once.
|
|
1134
995
|
def bind_metrics(session)
|
|
1135
|
-
return unless session
|
|
996
|
+
return unless session&.id
|
|
1136
997
|
|
|
1137
998
|
@metrics.state_dir = session_state_dir
|
|
1138
999
|
@metrics.session_id = session.id
|
|
@@ -1222,6 +1083,38 @@ module Samagotchi
|
|
|
1222
1083
|
end
|
|
1223
1084
|
private :record_last_turn
|
|
1224
1085
|
|
|
1086
|
+
# How every turn ends (completed, canceled, failed): the session gets
|
|
1087
|
+
# what the turn kept, goes idle and records the ending, and the end
|
|
1088
|
+
# event is announced, as one step of the event log (a snapshot taken
|
|
1089
|
+
# meanwhile, the Bridge's, shows the turn either in progress or in the
|
|
1090
|
+
# messages, never both or neither). Then the metrics are persisted.
|
|
1091
|
+
# The block runs in that step with the turn's seconds, once, and
|
|
1092
|
+
# returns [the messages to keep (nil: leave the session's), the end
|
|
1093
|
+
# event]. +save:+ also saves the session (a failed turn: a worker exits
|
|
1094
|
+
# after it). The disk writes come after the step: every emitter waits
|
|
1095
|
+
# for the event lock.
|
|
1096
|
+
def end_turn(turn, outcome, save: false)
|
|
1097
|
+
seconds = turn.elapsed
|
|
1098
|
+
synchronize_events do
|
|
1099
|
+
kept, event = yield(seconds)
|
|
1100
|
+
replace_session_messages(turn.session, kept) if kept
|
|
1101
|
+
turn.session.status = Session::STATUS_IDLE
|
|
1102
|
+
record_last_turn(turn.session, outcome, seconds, turn.origin)
|
|
1103
|
+
emit_event(turn.on_event, turn.tag(event))
|
|
1104
|
+
end
|
|
1105
|
+
turn.ended = true
|
|
1106
|
+
save_quietly(turn.session) if save
|
|
1107
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1108
|
+
end
|
|
1109
|
+
private :end_turn
|
|
1110
|
+
|
|
1111
|
+
def save_quietly(session)
|
|
1112
|
+
session.save(state_dir: session_state_dir)
|
|
1113
|
+
rescue StandardError
|
|
1114
|
+
nil
|
|
1115
|
+
end
|
|
1116
|
+
private :save_quietly
|
|
1117
|
+
|
|
1225
1118
|
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1226
1119
|
# and tell the observers (:answer_display), after the turn_completed
|
|
1227
1120
|
# they already had: the web re-reads the answer then. A turn_completed
|
|
@@ -1275,11 +1168,7 @@ module Samagotchi
|
|
|
1275
1168
|
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
|
|
1276
1169
|
return hold_load_event(notice) && nil if @loading_plugins
|
|
1277
1170
|
|
|
1278
|
-
sink =
|
|
1279
|
-
in_turn = @activity_mutex.synchronize do
|
|
1280
|
-
sink = @turn_event_sink
|
|
1281
|
-
@turn_running
|
|
1282
|
-
end
|
|
1171
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
1283
1172
|
if anytime_thread?
|
|
1284
1173
|
announce_anytime(notice.merge(between_turns: true))
|
|
1285
1174
|
elsif current_init_task
|
|
@@ -1420,10 +1309,10 @@ module Samagotchi
|
|
|
1420
1309
|
def session=(session)
|
|
1421
1310
|
@session = session
|
|
1422
1311
|
# One session per REPL/worker process: its records carry this sid.
|
|
1423
|
-
Log.session_id = session.id if session
|
|
1312
|
+
Log.session_id = session.id if session&.id
|
|
1424
1313
|
# A woken worker's /stats and status line count the turns before it.
|
|
1425
1314
|
bind_metrics(session)
|
|
1426
|
-
|
|
1315
|
+
@used_memories.absorb(session)
|
|
1427
1316
|
end
|
|
1428
1317
|
|
|
1429
1318
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
@@ -1451,7 +1340,7 @@ module Samagotchi
|
|
|
1451
1340
|
state = @recap&.state
|
|
1452
1341
|
return nil unless state
|
|
1453
1342
|
|
|
1454
|
-
messages = @
|
|
1343
|
+
messages = @session&.messages || []
|
|
1455
1344
|
covered = state[:covered].to_i
|
|
1456
1345
|
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
|
|
1457
1346
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
@@ -1473,10 +1362,27 @@ module Samagotchi
|
|
|
1473
1362
|
self
|
|
1474
1363
|
end
|
|
1475
1364
|
|
|
1365
|
+
# One turn's values, for #run_turn and its endings. Per turn and
|
|
1366
|
+
# single-threaded: the cross-thread turn state (the cancel slot, the
|
|
1367
|
+
# sink, steers) stays on the Engine.
|
|
1368
|
+
# +ended+: the end event is out (what follows is post-turn work).
|
|
1369
|
+
# +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
|
|
1370
|
+
# set on the kernel in #generate.
|
|
1371
|
+
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1372
|
+
:settings) do
|
|
1373
|
+
# The turn's boundary events carry the origin only when there is one,
|
|
1374
|
+
# so payloads stay unchanged for callers that don't pass it.
|
|
1375
|
+
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
1376
|
+
|
|
1377
|
+
# Seconds since the turn started (the cancel note says how long it ran).
|
|
1378
|
+
def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
1379
|
+
end
|
|
1380
|
+
private_constant :Turn
|
|
1381
|
+
|
|
1476
1382
|
# Run a single turn with event emission.
|
|
1477
1383
|
#
|
|
1478
1384
|
# Builds the system prompt + user messages, runs the kernel loop with
|
|
1479
|
-
# event forwarding, and returns
|
|
1385
|
+
# event forwarding, and returns its LLM::ModelResult.
|
|
1480
1386
|
#
|
|
1481
1387
|
# @param session [Session] the session to operate on
|
|
1482
1388
|
# @param prompt [String] user input
|
|
@@ -1485,7 +1391,7 @@ module Samagotchi
|
|
|
1485
1391
|
# @param cancel_controller [CancellationController, nil]
|
|
1486
1392
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1487
1393
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
1488
|
-
# @return [
|
|
1394
|
+
# @return [LLM::ModelResult]
|
|
1489
1395
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
1490
1396
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
1491
1397
|
# by the agentic loop at iteration boundaries (see KernelLoop#run).
|
|
@@ -1504,283 +1410,334 @@ module Samagotchi
|
|
|
1504
1410
|
#
|
|
1505
1411
|
# An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
|
|
1506
1412
|
# the prompt is kept in the session and :turn_canceled is emitted, then the
|
|
1507
|
-
# Interrupt is re-raised so the caller still decides whether to exit.
|
|
1413
|
+
# Interrupt is re-raised so the caller still decides whether to exit. One
|
|
1414
|
+
# after the turn's end event (in its after_turn/session_end hooks) is
|
|
1415
|
+
# only re-raised: the turn already ended. Any
|
|
1508
1416
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1509
1417
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1510
1418
|
# one-line summary:.
|
|
1511
1419
|
def run_turn(session, prompt, on_event: nil, max_iterations: 100, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1512
1420
|
images: [])
|
|
1421
|
+
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1422
|
+
continue: continue)
|
|
1423
|
+
begin
|
|
1424
|
+
# A Stop cuts the /props probes this thread makes for the turn
|
|
1425
|
+
# (window, served model, vision) instead of waiting their timeout.
|
|
1426
|
+
probe_cancel_before = Client.swap_probe_cancel(turn.controller)
|
|
1427
|
+
prepare_turn(turn, continue ? [] : images)
|
|
1428
|
+
result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
|
|
1429
|
+
pending_input: pending_input)
|
|
1430
|
+
publish_used_memories(session, on_event)
|
|
1431
|
+
complete_turn(turn, result)
|
|
1432
|
+
result
|
|
1433
|
+
rescue Interrupt
|
|
1434
|
+
# After the end event (in the after_turn/session_end hooks) the
|
|
1435
|
+
# turn is over: no second ending, the answer stays.
|
|
1436
|
+
raise if turn.ended
|
|
1437
|
+
|
|
1438
|
+
turn.controller.cancel!(:ctrl_c)
|
|
1439
|
+
end_turn(turn, "canceled") do |seconds|
|
|
1440
|
+
[ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1441
|
+
end
|
|
1442
|
+
raise
|
|
1443
|
+
rescue StandardError => e
|
|
1444
|
+
# Keep what the turn got to (the prompt plus the loop's completed
|
|
1445
|
+
# tool iterations) like a cancel does, and save it: a worker exits
|
|
1446
|
+
# after a failed turn. The REPL still rolls back to its checkpoint.
|
|
1447
|
+
end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
|
|
1448
|
+
raise
|
|
1449
|
+
ensure
|
|
1450
|
+
release_turn(probe_cancel_before)
|
|
1451
|
+
end
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
# The turn's state before anything can fail it: the session running,
|
|
1455
|
+
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
1456
|
+
# is set here and cleared in #release_turn only.
|
|
1457
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
|
|
1513
1458
|
# Track the active session for recap and status snapshot.
|
|
1514
1459
|
@session = session
|
|
1515
1460
|
# status is turn state: running now, idle again before the turn's end
|
|
1516
1461
|
# is announced, so a UI reacting to that event reads the new state.
|
|
1517
1462
|
session.status = Session::STATUS_RUNNING
|
|
1518
|
-
|
|
1519
|
-
#
|
|
1520
|
-
|
|
1521
|
-
#
|
|
1522
|
-
|
|
1463
|
+
@used_memories.absorb(session)
|
|
1464
|
+
# Provide a cancellable controller for this turn (cross-process cancel via file flag)
|
|
1465
|
+
effective_controller = cancel_controller || CancellationController.new
|
|
1466
|
+
# The turn runs, with its controller and sink, in one step: a Stop
|
|
1467
|
+
# or a plugin's card from another thread never sees a running turn
|
|
1468
|
+
# without them. The idle recap detector does not fire (or render an
|
|
1469
|
+
# invalidated recap) while the model is working. A hook's notice goes
|
|
1470
|
+
# where the turn's events go (the REPL renders only its sink; a
|
|
1471
|
+
# worker's observers carry it to the bridge).
|
|
1472
|
+
@turn_state.begin!(controller: effective_controller, sink: on_event)
|
|
1473
|
+
# Drop a recap already in flight: the turn makes it stale.
|
|
1523
1474
|
@recap&.invalidate!
|
|
1475
|
+
# The gate's context: who queued this turn, and git asked afresh.
|
|
1476
|
+
@guardrail_wiring.begin_turn(origin)
|
|
1477
|
+
|
|
1478
|
+
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
1479
|
+
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
1480
|
+
end
|
|
1481
|
+
private :begin_turn
|
|
1482
|
+
|
|
1483
|
+
# Everything before the model is asked: :turn_started, the kernel's
|
|
1484
|
+
# per-turn settings, the plugins' setup, the session_start/before_turn
|
|
1485
|
+
# hooks, then the messages to send (#turn_messages).
|
|
1486
|
+
def prepare_turn(turn, images)
|
|
1487
|
+
session = turn.session
|
|
1488
|
+
image_refs, image_error = turn_image_refs(session, images)
|
|
1489
|
+
# Emit turn_started event
|
|
1490
|
+
bind_metrics(session)
|
|
1491
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
|
|
1492
|
+
turn_started[:continue] = true if turn.continue
|
|
1493
|
+
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1494
|
+
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1495
|
+
raise image_error if image_error
|
|
1496
|
+
|
|
1524
1497
|
# Ask the server for its window again each turn (one short /props GET,
|
|
1525
1498
|
# cached across the turn's generations): a restart with another -c
|
|
1526
|
-
# between turns raises no error that would drop the cache.
|
|
1527
|
-
|
|
1499
|
+
# between turns raises no error that would drop the cache. The
|
|
1500
|
+
# profile may probe too (a first turn, a retry). Both run after
|
|
1501
|
+
# run_turn's probe-cancel swap, so a Stop cuts them, and a failure
|
|
1502
|
+
# ends the turn as turn_failed.
|
|
1503
|
+
@client.invalidate_context_window!
|
|
1528
1504
|
refresh_profile!
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1505
|
+
|
|
1506
|
+
# Before anything of the turn is kept or a reminder is used up.
|
|
1507
|
+
turn.settings = turn_settings(session)
|
|
1508
|
+
refuse_images!(turn.settings.vision) unless image_refs.empty?
|
|
1509
|
+
announce_guardrail_failures(turn.on_event)
|
|
1510
|
+
# Plugins' slow setup that brings tools (an MCP server's first
|
|
1511
|
+
# start): the turn waits for it here, before the system prompt
|
|
1512
|
+
# declares the tools; a Ctrl-C ends the wait (the turn is cancelled
|
|
1513
|
+
# at its first request).
|
|
1514
|
+
start_init_tasks!
|
|
1515
|
+
await_init_tasks(turn.controller, turn.on_event)
|
|
1516
|
+
# Plugins' tool sets that changed since the last turn.
|
|
1517
|
+
apply_staged_tools!
|
|
1518
|
+
|
|
1519
|
+
# Fire :session_start on the very first turn
|
|
1520
|
+
if @first_turn
|
|
1521
|
+
@hooks.fire(:session_start, { type: :session_start, session_id: session.id })
|
|
1522
|
+
@first_turn = false
|
|
1536
1523
|
end
|
|
1537
|
-
# The gate's context: who queued this turn, and git asked afresh.
|
|
1538
|
-
@guardrail_wiring.begin_turn(origin)
|
|
1539
1524
|
|
|
1540
|
-
|
|
1541
|
-
#
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1525
|
+
# Fire :before_turn hook, with a read-only copy of the history so far
|
|
1526
|
+
# and the prompt (nil on a continue).
|
|
1527
|
+
@hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
|
|
1528
|
+
messages: hook_messages(session.messages) })
|
|
1529
|
+
|
|
1530
|
+
turn_messages(turn, image_refs)
|
|
1531
|
+
end
|
|
1532
|
+
private :prepare_turn
|
|
1533
|
+
|
|
1534
|
+
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
1535
|
+
# sampling and thinking, the thinking resolved before the hooks run.
|
|
1536
|
+
# The model name is added in #generate, after the hooks that may switch
|
|
1537
|
+
# the model.
|
|
1538
|
+
def turn_settings(session)
|
|
1539
|
+
vision = turn_vision(session)
|
|
1540
|
+
sampling = turn_sampling
|
|
1541
|
+
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1542
|
+
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1543
|
+
announce_thinking_level(*@turn_thinking)
|
|
1544
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
|
|
1545
|
+
end
|
|
1546
|
+
private :turn_settings
|
|
1547
|
+
|
|
1548
|
+
# The messages the turn sends: the history under the system head, due
|
|
1549
|
+
# reminders, the prompt. turn.messages is set first and grown in place,
|
|
1550
|
+
# so a Ctrl-C or a failure meanwhile keeps what is there.
|
|
1551
|
+
def turn_messages(turn, image_refs)
|
|
1552
|
+
session = turn.session
|
|
1553
|
+
turn.messages = session.messages.dup
|
|
1554
|
+
# Built once per Engine (and again after a model switch) so the prompt
|
|
1555
|
+
# prefix, and the model server's KV cache for it, stay stable.
|
|
1556
|
+
turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
|
|
1557
|
+
# Explicit --memory preloads are now known after system prompt build.
|
|
1558
|
+
@used_memories.add(activated_memory_names).absorb(session)
|
|
1559
|
+
|
|
1560
|
+
# Inject due reminders as a tail system message (after history, before
|
|
1561
|
+
# the new user prompt) to preserve prefix KV cache. Mutating the head
|
|
1562
|
+
# system prompt invalidates cache for the entire prefix.
|
|
1563
|
+
due_reminders = @reminder_queue.inject!(turn.messages)
|
|
1564
|
+
if due_reminders.any?
|
|
1565
|
+
emit_event(turn.on_event, {
|
|
1566
|
+
type: :reminder_injected,
|
|
1567
|
+
reminders: due_reminders
|
|
1568
|
+
})
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
return if turn.continue
|
|
1572
|
+
|
|
1573
|
+
user_message = { role: "user", content: turn.prompt }
|
|
1574
|
+
user_message[:images] = image_refs unless image_refs.empty?
|
|
1575
|
+
turn.messages << user_message
|
|
1576
|
+
session.last_prompt = turn.prompt
|
|
1577
|
+
end
|
|
1578
|
+
private :turn_messages
|
|
1579
|
+
|
|
1580
|
+
# Ask the effective model's backend.
|
|
1581
|
+
def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
1582
|
+
sync_kernel_client!
|
|
1583
|
+
# Route model name as bare (without host prefix) to the transport;
|
|
1584
|
+
# host selection already done via active client.
|
|
1585
|
+
bare_for_backend = bare_model_name(@effective_model_name)
|
|
1586
|
+
# The turn's settings, set once here. The chat loop dispatches tools
|
|
1587
|
+
# through the kernel without its #run: tag those dumps with this
|
|
1588
|
+
# turn's model, not the last native one.
|
|
1589
|
+
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
|
|
1590
|
+
|
|
1591
|
+
backend.complete(
|
|
1592
|
+
messages: turn.messages,
|
|
1593
|
+
max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
|
|
1594
|
+
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
1595
|
+
cancel_controller: turn.controller,
|
|
1596
|
+
model_name: bare_for_backend,
|
|
1597
|
+
max_tool_output_chars: max_tool_output_chars,
|
|
1598
|
+
pending_input: turn_drain(pending_input)
|
|
1599
|
+
)
|
|
1600
|
+
end
|
|
1601
|
+
private :generate
|
|
1602
|
+
|
|
1603
|
+
def publish_used_memories(session, on_event)
|
|
1604
|
+
# Persist deduped used memories onto the session for Web + reload.
|
|
1548
1605
|
begin
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1558
|
-
emit_event(on_event, with_origin.call(turn_started))
|
|
1559
|
-
raise image_error if image_error
|
|
1560
|
-
|
|
1561
|
-
# Before anything of the turn is kept or a reminder is used up.
|
|
1562
|
-
vision = turn_vision(session)
|
|
1563
|
-
@kernel.vision = vision if @kernel.respond_to?(:vision=)
|
|
1564
|
-
@kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
|
|
1565
|
-
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1566
|
-
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1567
|
-
@kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
|
|
1568
|
-
announce_thinking_level(*@turn_thinking)
|
|
1569
|
-
refuse_images!(vision) unless image_refs.empty?
|
|
1570
|
-
announce_guardrail_failures(on_event)
|
|
1571
|
-
# Plugins' slow setup that brings tools (an MCP server's first
|
|
1572
|
-
# start): the turn waits for it here, before the system prompt
|
|
1573
|
-
# declares the tools; a Ctrl-C ends the wait (the turn is cancelled
|
|
1574
|
-
# at its first request).
|
|
1575
|
-
start_init_tasks!
|
|
1576
|
-
await_init_tasks(effective_controller, on_event)
|
|
1577
|
-
# Plugins' tool sets that changed since the last turn.
|
|
1578
|
-
apply_staged_tools!
|
|
1579
|
-
|
|
1580
|
-
# Fire :session_start on the very first turn
|
|
1581
|
-
if @first_turn
|
|
1582
|
-
@hooks.fire(:session_start, { type: :session_start, session_id: session.id })
|
|
1583
|
-
@first_turn = false
|
|
1584
|
-
end
|
|
1606
|
+
session.used_memory_names = used_memory_names
|
|
1607
|
+
rescue StandardError
|
|
1608
|
+
nil
|
|
1609
|
+
end
|
|
1610
|
+
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
1611
|
+
# even without a tool_call event if the preload was the only addition).
|
|
1612
|
+
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
1613
|
+
return unless used_memory_names.any?
|
|
1585
1614
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
if
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1615
|
+
begin
|
|
1616
|
+
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
1617
|
+
rescue StandardError
|
|
1618
|
+
nil
|
|
1619
|
+
end
|
|
1620
|
+
end
|
|
1621
|
+
private :publish_used_memories
|
|
1622
|
+
|
|
1623
|
+
# The normal ending (an answer, an empty answer, a Stop, the iteration
|
|
1624
|
+
# limit), then the after-turn hooks.
|
|
1625
|
+
def complete_turn(turn, result)
|
|
1626
|
+
canceled = result.canceled?
|
|
1627
|
+
# after_turn hooks run below and may present the answer (the web
|
|
1628
|
+
# holds its pop until it knows).
|
|
1629
|
+
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1630
|
+
end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
|
|
1631
|
+
kept = kept_messages(turn, result, seconds)
|
|
1632
|
+
if canceled
|
|
1633
|
+
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
1634
|
+
duration_ms: (seconds * 1000).round }]
|
|
1635
|
+
else
|
|
1636
|
+
# For a client that attaches later (session_state_snapshot).
|
|
1637
|
+
@last_context_status = result.context_status.dup if result.context_status
|
|
1638
|
+
[kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
|
|
1639
|
+
display_pending: display_pending }]
|
|
1608
1640
|
end
|
|
1641
|
+
end
|
|
1609
1642
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1643
|
+
# Fire :after_turn hook (runs even on cancel/success), with a read-only
|
|
1644
|
+
# copy of the conversation the turn stored, and event[:present] for
|
|
1645
|
+
# a display version of the answer (AnswerDisplay).
|
|
1646
|
+
session = turn.session
|
|
1647
|
+
display = AnswerDisplay.new(session.messages)
|
|
1648
|
+
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
1649
|
+
messages: hook_messages(session.messages) }
|
|
1650
|
+
after_turn[:present] = display.presenter(after_turn)
|
|
1651
|
+
@hooks.fire(:after_turn, after_turn)
|
|
1652
|
+
store_answer_display(session, display, pending: display_pending)
|
|
1653
|
+
|
|
1654
|
+
# Fire :session_end after every turn (turn-level lifecycle)
|
|
1655
|
+
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
1656
|
+
end
|
|
1657
|
+
private :complete_turn
|
|
1658
|
+
|
|
1659
|
+
# What the session keeps of a turn that ended normally (nil: nothing to
|
|
1660
|
+
# replace). Runs under the event lock: it also adds the turn's note to
|
|
1661
|
+
# result.conversation, which the REPL keeps as-is.
|
|
1662
|
+
# A turn that ran out of iterations ends at its tool results, so a
|
|
1663
|
+
# continue resumes from them rather than after a made-up reply.
|
|
1664
|
+
def kept_messages(turn, result, seconds)
|
|
1665
|
+
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
1666
|
+
# Nothing visible (the native loop: no text; the chat loop: its
|
|
1667
|
+
# placeholder text) is a turn the model should know ended that way.
|
|
1668
|
+
if result.empty_answer?
|
|
1669
|
+
# The placeholder is for the UIs (a new array: it must not leak
|
|
1670
|
+
# into the result); the note is for the model, so it goes on the
|
|
1671
|
+
# result's conversation too.
|
|
1672
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1673
|
+
note = TurnNote.empty
|
|
1674
|
+
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1675
|
+
saved = TurnNote.without_trailing(conversation || turn.session.messages)
|
|
1676
|
+
saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
|
|
1677
|
+
conversation << note if conversation
|
|
1678
|
+
saved + [note]
|
|
1679
|
+
elsif result.canceled? && conversation
|
|
1680
|
+
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
|
|
1681
|
+
stopped_by: turn.controller.detail,
|
|
1682
|
+
shown: TurnNote.interrupted_tail?(conversation),
|
|
1683
|
+
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1684
|
+
else
|
|
1685
|
+
conversation
|
|
1686
|
+
end
|
|
1687
|
+
end
|
|
1688
|
+
private :kept_messages
|
|
1616
1689
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
# tag those dumps with this turn's model, not the last native one.
|
|
1623
|
-
@kernel.current_model_name = bare_for_backend if @kernel.respond_to?(:current_model_name=)
|
|
1624
|
-
|
|
1625
|
-
result = backend.complete(
|
|
1626
|
-
messages: messages,
|
|
1627
|
-
max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
|
|
1628
|
-
on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
|
|
1629
|
-
cancel_controller: effective_controller,
|
|
1630
|
-
model_name: bare_for_backend,
|
|
1631
|
-
max_tool_output_chars: max_tool_output_chars,
|
|
1632
|
-
pending_input: turn_drain(pending_input)
|
|
1633
|
-
)
|
|
1634
|
-
|
|
1635
|
-
# Persist deduped used memories onto the session for Web + reload.
|
|
1636
|
-
begin
|
|
1637
|
-
session.used_memory_names = used_memory_names
|
|
1638
|
-
rescue StandardError
|
|
1639
|
-
nil
|
|
1640
|
-
end
|
|
1641
|
-
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
1642
|
-
# even without a tool_call event if the preload was the only addition).
|
|
1643
|
-
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
1644
|
-
if used_memory_names.any?
|
|
1645
|
-
begin
|
|
1646
|
-
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
1647
|
-
rescue StandardError
|
|
1648
|
-
nil
|
|
1649
|
-
end
|
|
1650
|
-
end
|
|
1690
|
+
# A Ctrl-C keeps the turn's messages so far with a cancel note (nil
|
|
1691
|
+
# before there are any). Only the pre-turn messages survive here, so
|
|
1692
|
+
# tasks this turn started are missed (plan wait-stop §6).
|
|
1693
|
+
def ctrl_c_messages(turn, seconds)
|
|
1694
|
+
return nil unless turn.messages
|
|
1651
1695
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
# the event log: a snapshot taken meanwhile (the Bridge's) shows the
|
|
1657
|
-
# turn either in progress or in the messages, never both or neither.
|
|
1658
|
-
# A turn that ran out of iterations ends at its tool results, so a
|
|
1659
|
-
# continue resumes from them rather than after a made-up reply.
|
|
1660
|
-
resumable = result.respond_to?(:resumable?) && result.resumable?
|
|
1661
|
-
# Nothing visible (the native loop: no text; the chat loop: its
|
|
1662
|
-
# placeholder text) is a turn the model should know ended that way.
|
|
1663
|
-
empty = !canceled && !resumable &&
|
|
1664
|
-
(response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
|
|
1665
|
-
# after_turn hooks run below and may present the answer (the web
|
|
1666
|
-
# holds its pop until it knows).
|
|
1667
|
-
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1668
|
-
synchronize_events do
|
|
1669
|
-
if empty
|
|
1670
|
-
# The placeholder is for the UIs (a new array: it must not leak
|
|
1671
|
-
# into the result); the note is for the model, so it goes on the
|
|
1672
|
-
# result's conversation too, which the REPL keeps as-is.
|
|
1673
|
-
# A retry's nudge at the tail goes: this note says it all.
|
|
1674
|
-
note = TurnNote.empty
|
|
1675
|
-
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1676
|
-
saved = TurnNote.without_trailing(conversation || session.messages)
|
|
1677
|
-
saved << { role: "model", content: "[No response]" } if response.strip.empty?
|
|
1678
|
-
replace_session_messages(session, saved + [note])
|
|
1679
|
-
conversation << note if conversation
|
|
1680
|
-
elsif canceled && conversation
|
|
1681
|
-
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
|
|
1682
|
-
stopped_by: effective_controller.detail,
|
|
1683
|
-
shown: TurnNote.interrupted_tail?(conversation),
|
|
1684
|
-
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1685
|
-
replace_session_messages(session, conversation)
|
|
1686
|
-
elsif conversation
|
|
1687
|
-
replace_session_messages(session, conversation)
|
|
1688
|
-
end
|
|
1689
|
-
session.status = Session::STATUS_IDLE
|
|
1690
|
-
record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
|
|
1691
|
-
if canceled
|
|
1692
|
-
emit_event(on_event, with_origin.call({
|
|
1693
|
-
type: :turn_canceled,
|
|
1694
|
-
cancellation_reason: result.cancellation_reason,
|
|
1695
|
-
duration_ms: (turn_seconds.call * 1000).round
|
|
1696
|
-
}))
|
|
1697
|
-
else
|
|
1698
|
-
# For a client that attaches later (session_state_snapshot).
|
|
1699
|
-
@last_context_status = result.context_status.dup if result.context_status
|
|
1700
|
-
emit_event(on_event, with_origin.call({
|
|
1701
|
-
type: :turn_completed,
|
|
1702
|
-
result: result,
|
|
1703
|
-
turn_summary: turn_summary(result),
|
|
1704
|
-
display_pending: display_pending
|
|
1705
|
-
}))
|
|
1706
|
-
end
|
|
1707
|
-
end
|
|
1708
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
1696
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(turn.messages))
|
|
1697
|
+
TurnNote.replace_trailing(turn.messages, note)
|
|
1698
|
+
end
|
|
1699
|
+
private :ctrl_c_messages
|
|
1709
1700
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
@hooks.fire(:after_turn, after_turn)
|
|
1718
|
-
store_answer_display(session, display, pending: display_pending)
|
|
1701
|
+
# A failure keeps the loop's partial conversation (else the turn's
|
|
1702
|
+
# messages so far) with a failed note; nil when the turn never reached
|
|
1703
|
+
# the model. The model reads why on its next turn (a UI that rolls the
|
|
1704
|
+
# turn back leaves its own note, TurnFlow#prompt_turn_failed).
|
|
1705
|
+
def failed_messages(turn, error)
|
|
1706
|
+
kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
|
|
1707
|
+
return nil unless kept
|
|
1719
1708
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1709
|
+
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
1710
|
+
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
|
|
1711
|
+
end
|
|
1712
|
+
private :failed_messages
|
|
1722
1713
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
if messages
|
|
1730
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
|
|
1731
|
-
running_tasks: Tools::TaskRuntime.running_created_in(messages))
|
|
1732
|
-
replace_session_messages(session, TurnNote.replace_trailing(messages, note))
|
|
1733
|
-
end
|
|
1734
|
-
session.status = Session::STATUS_IDLE
|
|
1735
|
-
record_last_turn(session, "canceled", turn_seconds.call, origin)
|
|
1736
|
-
emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c,
|
|
1737
|
-
duration_ms: (turn_seconds.call * 1000).round }))
|
|
1738
|
-
end
|
|
1739
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
1740
|
-
raise
|
|
1741
|
-
rescue StandardError => e
|
|
1742
|
-
# Keep what the turn got to (the prompt plus the loop's completed
|
|
1743
|
-
# tool iterations) like a cancel does, and save it: a worker exits
|
|
1744
|
-
# after a failed turn. The REPL still rolls back to its checkpoint.
|
|
1745
|
-
kept = e.respond_to?(:partial_conversation) && e.partial_conversation.is_a?(Array) ? e.partial_conversation : messages
|
|
1746
|
-
# The model reads why on its next turn (a UI that rolls the turn back
|
|
1747
|
-
# leaves its own note, TurnFlow#prompt_turn_failed). Nothing when the
|
|
1748
|
-
# turn never reached the model (kept is nil).
|
|
1749
|
-
if kept
|
|
1750
|
-
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
1751
|
-
kept = TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: continue))
|
|
1752
|
-
end
|
|
1753
|
-
replace_session_messages(session, kept) if kept
|
|
1754
|
-
session.status = Session::STATUS_IDLE
|
|
1755
|
-
record_last_turn(session, "failed", turn_seconds.call, origin)
|
|
1756
|
-
begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1757
|
-
failed = { type: :turn_failed, error_class: e.class.name, message: e.message,
|
|
1758
|
-
duration_ms: (turn_seconds.call * 1000).round }
|
|
1759
|
-
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1760
|
-
if e.is_a?(LLM::ProviderError)
|
|
1761
|
-
failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
|
|
1762
|
-
end
|
|
1763
|
-
emit_event(on_event, with_origin.call(failed))
|
|
1764
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
1765
|
-
raise
|
|
1766
|
-
ensure
|
|
1767
|
-
# A completed turn is activity: release the turn flag and advance the
|
|
1768
|
-
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1769
|
-
# treats the just-finished turn as activity and re-arms its window.
|
|
1770
|
-
# Always runs, even if an exception occurred.
|
|
1771
|
-
set_turn_running(false)
|
|
1772
|
-
Client.swap_probe_cancel(probe_cancel_before)
|
|
1773
|
-
left = @activity_mutex.synchronize do
|
|
1774
|
-
@active_cancel_controller = nil
|
|
1775
|
-
@turn_event_sink = nil
|
|
1776
|
-
@steers.tap { @steers = [] }
|
|
1777
|
-
end
|
|
1778
|
-
log_dropped_steers(left, "turn_ended")
|
|
1779
|
-
record_activity
|
|
1780
|
-
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1781
|
-
clear_hooks
|
|
1714
|
+
def failed_event(error, seconds)
|
|
1715
|
+
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
1716
|
+
duration_ms: (seconds * 1000).round }
|
|
1717
|
+
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1718
|
+
if error.is_a?(LLM::ProviderError)
|
|
1719
|
+
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
|
|
1782
1720
|
end
|
|
1783
|
-
|
|
1721
|
+
failed
|
|
1722
|
+
end
|
|
1723
|
+
private :failed_event
|
|
1724
|
+
|
|
1725
|
+
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
1726
|
+
# probe cancel restored, the cancel controller, sink and steers cleared
|
|
1727
|
+
# (a steer left is logged as dropped), activity recorded, the
|
|
1728
|
+
# turn-scoped hooks cleared.
|
|
1729
|
+
def release_turn(probe_cancel_before)
|
|
1730
|
+
# A completed turn is activity: release the turn flag and advance the
|
|
1731
|
+
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1732
|
+
# treats the just-finished turn as activity and re-arms its window.
|
|
1733
|
+
left = @turn_state.finish!
|
|
1734
|
+
Client.swap_probe_cancel(probe_cancel_before)
|
|
1735
|
+
log_dropped_steers(left, "turn_ended")
|
|
1736
|
+
record_activity
|
|
1737
|
+
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1738
|
+
clear_hooks
|
|
1739
|
+
end
|
|
1740
|
+
private :release_turn
|
|
1784
1741
|
|
|
1785
1742
|
# [refs, nil] for a turn's images, or [[], error] when one can't be used
|
|
1786
1743
|
# (the turn then fails right after :turn_started).
|
|
@@ -1839,7 +1796,7 @@ module Samagotchi
|
|
|
1839
1796
|
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
1840
1797
|
|
|
1841
1798
|
client = target.client
|
|
1842
|
-
props = client.
|
|
1799
|
+
props = client.cached_server_props(model: target.bare_model)
|
|
1843
1800
|
return unless Thinking.effort_ignored?(level, props)
|
|
1844
1801
|
|
|
1845
1802
|
thinking_notice_once(:unsupported, target, :info,
|
|
@@ -1964,8 +1921,16 @@ module Samagotchi
|
|
|
1964
1921
|
|
|
1965
1922
|
private
|
|
1966
1923
|
|
|
1924
|
+
# The one named seam for replacing a session's conversation (the
|
|
1925
|
+
# array is replaced, never mutated in place). No lock: a reader gets
|
|
1926
|
+
# the old array or the new one, and both are whole.
|
|
1927
|
+
# The turn as other threads see it (TurnState).
|
|
1928
|
+
attr_reader :turn_state
|
|
1929
|
+
# The due reminders on their way into a turn (ReminderQueue).
|
|
1930
|
+
attr_reader :reminder_queue
|
|
1931
|
+
|
|
1967
1932
|
def replace_session_messages(session, messages)
|
|
1968
|
-
|
|
1933
|
+
session.messages = messages
|
|
1969
1934
|
end
|
|
1970
1935
|
|
|
1971
1936
|
# Pick the loop for a target; the chat loop is pointed at the target
|
|
@@ -2081,11 +2046,9 @@ module Samagotchi
|
|
|
2081
2046
|
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
|
|
2082
2047
|
hook_ask_user(question, options, header, allow_freeform, hook)
|
|
2083
2048
|
},
|
|
2084
|
-
# Nothing to cancel while the Engine is still being built (a
|
|
2085
|
-
# server that starts as its plugin loads).
|
|
2086
2049
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2087
2050
|
cancelled: lambda {
|
|
2088
|
-
(task = current_init_task) ? task.cancelled? :
|
|
2051
|
+
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|
|
2089
2052
|
},
|
|
2090
2053
|
card: ->(**card) { show_card(**card) },
|
|
2091
2054
|
steer: ->(text, label) { steer(text, source: hook_source(label)) },
|
|
@@ -2159,7 +2122,7 @@ module Samagotchi
|
|
|
2159
2122
|
@auto_turn_callback = auto_turn_callback
|
|
2160
2123
|
IdleReminders.new(
|
|
2161
2124
|
engine: self,
|
|
2162
|
-
|
|
2125
|
+
queue: @reminder_queue,
|
|
2163
2126
|
callback: @auto_turn_callback
|
|
2164
2127
|
)
|
|
2165
2128
|
end
|
|
@@ -2221,7 +2184,7 @@ module Samagotchi
|
|
|
2221
2184
|
def emit_event(on_event, event)
|
|
2222
2185
|
# Capture used memories synchronously in the turn thread.
|
|
2223
2186
|
read_names = begin
|
|
2224
|
-
|
|
2187
|
+
@used_memories.capture(event, muted: @muted_memory_names)
|
|
2225
2188
|
rescue StandardError
|
|
2226
2189
|
nil
|
|
2227
2190
|
end
|
|
@@ -2253,7 +2216,7 @@ module Samagotchi
|
|
|
2253
2216
|
adapter = nil
|
|
2254
2217
|
if target.entry.chat?
|
|
2255
2218
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2256
|
-
client = nil if adapter.
|
|
2219
|
+
client = nil if adapter.remote?
|
|
2257
2220
|
end
|
|
2258
2221
|
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
|
|
2259
2222
|
rescue StandardError
|
|
@@ -2271,9 +2234,9 @@ module Samagotchi
|
|
|
2271
2234
|
client = target.client
|
|
2272
2235
|
if target.entry.chat?
|
|
2273
2236
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2274
|
-
return [nil, nil] if adapter.
|
|
2237
|
+
return [nil, nil] if adapter.remote?
|
|
2275
2238
|
end
|
|
2276
|
-
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2239
|
+
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2277
2240
|
served ? [served, asked] : [nil, nil]
|
|
2278
2241
|
rescue StandardError
|
|
2279
2242
|
[nil, nil]
|
|
@@ -2300,7 +2263,7 @@ module Samagotchi
|
|
|
2300
2263
|
# prompt format and parser, and the system prompts built for the old one.
|
|
2301
2264
|
def apply_profile(resolution)
|
|
2302
2265
|
@prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
|
|
2303
|
-
@kernel.use_profile!(resolution)
|
|
2266
|
+
@kernel.use_profile!(resolution)
|
|
2304
2267
|
resolution
|
|
2305
2268
|
end
|
|
2306
2269
|
|