samagotchi 0.10.1 → 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 +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- 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/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- 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 +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- 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 +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- metadata +13 -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"
|
|
@@ -76,20 +81,31 @@ module Samagotchi
|
|
|
76
81
|
# @param client [Client, nil] defaults to Client.new
|
|
77
82
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
78
83
|
# @param session_id [String, nil] resume an existing session
|
|
79
|
-
# @param no_interrupt [Boolean]
|
|
84
|
+
# @param no_interrupt [Boolean] --no-interrupt: every turn runs with
|
|
85
|
+
# NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
|
|
80
86
|
# @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
|
|
87
|
+
# @param model_typed [String, nil] the name the model was given as (a session's
|
|
88
|
+
# model_typed: an alias), for the models: lookup; model_name by default
|
|
81
89
|
# @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
|
|
82
90
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
83
91
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
84
92
|
# What memory_write answers in a scratch session.
|
|
85
93
|
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
94
|
+
# A turn's iteration limit with no_interrupt (the worker's own
|
|
95
|
+
# --no-interrupt turns pass the same).
|
|
96
|
+
NO_INTERRUPT_MAX_ITERATIONS = 1000
|
|
86
97
|
|
|
87
98
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
88
99
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
89
100
|
# delegate (a child would outlive it) nor plugin fork
|
|
90
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,
|
|
91
|
-
plugins: true, scratch: false)
|
|
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 })
|
|
92
107
|
@scratch = scratch
|
|
108
|
+
@no_interrupt = no_interrupt
|
|
93
109
|
@chat_backend = nil
|
|
94
110
|
@chat_backend_mutex = Mutex.new
|
|
95
111
|
@host_registry = host_registry || HostRegistry.new
|
|
@@ -106,7 +122,7 @@ module Samagotchi
|
|
|
106
122
|
# #profile_resolution decides on first need (see there), so building an
|
|
107
123
|
# Engine makes no network call.
|
|
108
124
|
@given_profile = profile ? ModelProfile.normalize(profile) : nil
|
|
109
|
-
@typed_model_name = @default_model_name
|
|
125
|
+
@typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
|
|
110
126
|
@profile_resolution = nil
|
|
111
127
|
# Ensure the built-in system bundle is installed (lazy, warn-only).
|
|
112
128
|
# This is the single seam for both TUI and non-TUI (web/worker) paths.
|
|
@@ -182,48 +198,41 @@ module Samagotchi
|
|
|
182
198
|
@shut_down = false
|
|
183
199
|
load_plugins if plugins
|
|
184
200
|
guardrail_rules
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
@reminder_store = ReminderStore.new
|
|
193
|
-
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)
|
|
194
208
|
# Build the idle reminders detector (wired to reminder_store)
|
|
195
209
|
callback = reminders.is_a?(Hash) && reminders[:callback] ? reminders[:callback] : nil
|
|
196
210
|
@reminders = build_reminders(auto_turn_callback: callback)
|
|
197
211
|
# Track whether this is the first turn in the session (for session_start event)
|
|
198
212
|
@first_turn = true
|
|
199
|
-
|
|
213
|
+
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
214
|
+
# the one built here does.
|
|
215
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
|
|
200
216
|
tools: @tools)
|
|
217
|
+
@kernel.reminder_store = @reminder_store
|
|
218
|
+
@kernel.hooks = @hooks
|
|
219
|
+
@kernel.tools = @tools
|
|
201
220
|
sync_kernel_client!
|
|
202
|
-
|
|
203
|
-
@kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
|
|
221
|
+
sync_model_key!
|
|
204
222
|
# The mutes never change during a session, so no re-sync: the kernel's
|
|
205
223
|
# memory_read guard reads the same list for every turn.
|
|
206
224
|
@muted_memory_names = MutedMemories.normalize_list(muted_memories)
|
|
207
|
-
@kernel.muted_memory_names = @muted_memory_names
|
|
225
|
+
@kernel.muted_memory_names = @muted_memory_names
|
|
208
226
|
# Keep kernel client in sync with active host via setter
|
|
209
227
|
@kernel_client_synced = false
|
|
210
|
-
# Engine owns hooks; if a kernel was supplied externally (TUI path) propagate
|
|
211
|
-
# the Engine's registry so all UIs reuse the same instance. Without this the
|
|
212
|
-
# TUI's KernelLoop fires with nil hooks and before_generation/after_generation
|
|
213
|
-
# etc. never fire in interactive mode.
|
|
214
|
-
if kernel && @kernel.respond_to?(:hooks=)
|
|
215
|
-
@kernel.hooks = @hooks
|
|
216
|
-
end
|
|
217
|
-
# Likewise its tools: the REPL builds its kernel before the Engine.
|
|
218
|
-
@kernel.tools = @tools if kernel && @kernel.respond_to?(:tools=)
|
|
219
228
|
# ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
|
|
220
|
-
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
229
|
+
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
221
230
|
# Every tool call asks this gate first. The kernel is never rebuilt, so
|
|
222
231
|
# it holds across model switches.
|
|
223
232
|
self.guardrail_state_dir = Session.default_state_dir
|
|
224
233
|
# list_sessions and send_note speak for whichever session runs now.
|
|
225
|
-
@kernel.peers = PeerView.new(self)
|
|
226
|
-
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
234
|
+
@kernel.peers = PeerView.new(self)
|
|
235
|
+
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
227
236
|
# What a hook can do beyond reading its event (event[:notify],
|
|
228
237
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
229
238
|
@hooks.runtime = hook_runtime
|
|
@@ -238,29 +247,18 @@ module Samagotchi
|
|
|
238
247
|
@session = nil
|
|
239
248
|
@session_observer = SessionObserver.new
|
|
240
249
|
@metrics = SessionMetrics.new
|
|
241
|
-
@
|
|
242
|
-
@used_memory_mutex = Monitor.new
|
|
250
|
+
@used_memories = UsedMemories.new
|
|
243
251
|
# Hydrate from resumed session if present
|
|
244
|
-
if @resume_session
|
|
245
|
-
@
|
|
252
|
+
if @resume_session
|
|
253
|
+
@used_memories.absorb(@resume_session)
|
|
246
254
|
@session = @resume_session
|
|
247
255
|
end
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
@
|
|
254
|
-
@last_activity_at = monotonic_now
|
|
255
|
-
@activity_seq = 0
|
|
256
|
-
@turn_running = false
|
|
257
|
-
@active_cancel_controller = nil
|
|
258
|
-
# Plugin steers ({text:, source:}) waiting for the running turn's next
|
|
259
|
-
# boundary (#steer); guarded by @activity_mutex.
|
|
260
|
-
@steers = []
|
|
261
|
-
# Reminder names the interactive REPL's IdleReminders callback marked due;
|
|
262
|
-
# the REPL polls them to decide when to run a synthetic reminder turn.
|
|
263
|
-
@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!
|
|
264
262
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
265
263
|
session_target: -> { session_model_recap_target },
|
|
266
264
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -290,37 +288,29 @@ module Samagotchi
|
|
|
290
288
|
backend_for(@host_registry.resolve(@effective_model_name))
|
|
291
289
|
end
|
|
292
290
|
|
|
293
|
-
# Record that activity happened (user input or a completed turn)
|
|
294
|
-
#
|
|
295
|
-
#
|
|
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.
|
|
296
294
|
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
297
295
|
def record_activity(now = nil)
|
|
298
|
-
@
|
|
299
|
-
@last_activity_at = now ? now.to_f : monotonic_now
|
|
300
|
-
@activity_seq += 1
|
|
301
|
-
end
|
|
296
|
+
@turn_state.record_activity(now)
|
|
302
297
|
end
|
|
303
298
|
|
|
304
299
|
# @return [Float] monotonic seconds of the last recorded activity
|
|
305
300
|
def last_activity_at
|
|
306
|
-
@
|
|
301
|
+
@turn_state.last_activity_at
|
|
307
302
|
end
|
|
308
303
|
|
|
309
304
|
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
310
305
|
# #record_activity; lets the idle detector summarize once per idle window)
|
|
311
306
|
def activity_seq
|
|
312
|
-
@
|
|
307
|
+
@turn_state.activity_seq
|
|
313
308
|
end
|
|
314
309
|
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
def set_turn_running(running)
|
|
318
|
-
@activity_mutex.synchronize { @turn_running = running }
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# @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)
|
|
322
312
|
def turn_running?
|
|
323
|
-
@
|
|
313
|
+
@turn_state.running?
|
|
324
314
|
end
|
|
325
315
|
|
|
326
316
|
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
@@ -331,15 +321,7 @@ module Samagotchi
|
|
|
331
321
|
# the turn ends first, it is dropped (logged).
|
|
332
322
|
# @return [Boolean] whether it was queued
|
|
333
323
|
def steer(text, source:)
|
|
334
|
-
text
|
|
335
|
-
return false if text.empty?
|
|
336
|
-
|
|
337
|
-
@activity_mutex.synchronize do
|
|
338
|
-
return false unless @turn_running
|
|
339
|
-
|
|
340
|
-
@steers << { text: text, source: source.to_s }
|
|
341
|
-
end
|
|
342
|
-
true
|
|
324
|
+
@turn_state.steer(text, source: source)
|
|
343
325
|
end
|
|
344
326
|
|
|
345
327
|
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
@@ -356,11 +338,7 @@ module Samagotchi
|
|
|
356
338
|
private :turn_drain
|
|
357
339
|
|
|
358
340
|
def take_steers(drop)
|
|
359
|
-
steers = @
|
|
360
|
-
taken = @steers
|
|
361
|
-
@steers = []
|
|
362
|
-
taken
|
|
363
|
-
end
|
|
341
|
+
steers = @turn_state.take_steers
|
|
364
342
|
return steers unless drop
|
|
365
343
|
|
|
366
344
|
log_dropped_steers(steers, "answered")
|
|
@@ -377,41 +355,37 @@ module Samagotchi
|
|
|
377
355
|
|
|
378
356
|
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
379
357
|
def due_reminder_names
|
|
380
|
-
@
|
|
358
|
+
@reminder_queue.pending_names
|
|
381
359
|
end
|
|
382
360
|
|
|
383
361
|
# Queue reminder names for a synthetic REPL turn (IdleReminders callback).
|
|
384
362
|
def note_due_reminders(names)
|
|
385
|
-
@
|
|
363
|
+
@reminder_queue.note_pending(names)
|
|
386
364
|
end
|
|
387
365
|
|
|
388
366
|
def clear_due_reminder_names!
|
|
389
|
-
@
|
|
367
|
+
@reminder_queue.clear_pending!
|
|
390
368
|
end
|
|
391
369
|
|
|
392
370
|
# @return [CancellationController, nil] active turn's cancellation controller
|
|
393
371
|
def active_cancel_controller
|
|
394
|
-
@
|
|
372
|
+
@turn_state.controller
|
|
395
373
|
end
|
|
396
374
|
|
|
397
375
|
# Cancel the currently running turn, if any.
|
|
398
376
|
# @param reason [Symbol] cancellation reason
|
|
399
377
|
# @return [Boolean] whether a cancellation was triggered
|
|
400
378
|
def cancel_current_turn!(reason = :manual)
|
|
401
|
-
|
|
402
|
-
return false unless ctrl
|
|
403
|
-
|
|
404
|
-
ctrl.cancel!(reason)
|
|
379
|
+
@turn_state.cancel!(reason)
|
|
405
380
|
end
|
|
406
381
|
|
|
407
382
|
# Snapshot the current session messages as a JSON string for the idle
|
|
408
|
-
# recap.
|
|
409
|
-
#
|
|
410
|
-
#
|
|
411
|
-
# 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.
|
|
412
386
|
# @return [String] JSON array of the messages
|
|
413
387
|
def messages_json_for_recap
|
|
414
|
-
snapshot = @
|
|
388
|
+
snapshot = @session&.messages
|
|
415
389
|
return "[]" if snapshot.nil?
|
|
416
390
|
|
|
417
391
|
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
@@ -426,7 +400,30 @@ module Samagotchi
|
|
|
426
400
|
|
|
427
401
|
# @return [SessionMetrics] the per-session analytics collector
|
|
428
402
|
attr_reader :metrics
|
|
429
|
-
|
|
403
|
+
# effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
|
|
404
|
+
# applies it); typed_model_name: as typed at start or at the last /model.
|
|
405
|
+
attr_reader :default_model_name, :effective_model_name, :typed_model_name
|
|
406
|
+
|
|
407
|
+
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
408
|
+
# the alias applied, "host:id" when it names a host).
|
|
409
|
+
def effective_model_ref
|
|
410
|
+
model_ref_for(@effective_model_name)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# +name+'s resolved ref (ModelRef#ref).
|
|
414
|
+
def model_ref_for(name)
|
|
415
|
+
@host_registry.model_ref(name).ref
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Writes the effective model to +session+: the resolved ref, and the
|
|
419
|
+
# name as typed when that differs (model_typed: an alias, for the
|
|
420
|
+
# models: lookup after a resume).
|
|
421
|
+
def store_model!(session)
|
|
422
|
+
ref = effective_model_ref
|
|
423
|
+
session.model_name = ref
|
|
424
|
+
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
|
|
425
|
+
session
|
|
426
|
+
end
|
|
430
427
|
|
|
431
428
|
# The effective model's key (ModelOverlay.key_for its bare name): memory
|
|
432
429
|
# overlays, guardrail rules' models:.
|
|
@@ -453,10 +450,23 @@ module Samagotchi
|
|
|
453
450
|
# built-ins, and the ones bundle plugins add
|
|
454
451
|
attr_reader :command_registry
|
|
455
452
|
|
|
453
|
+
# The model id sent for +full_ref+ (its alias applied, the host prefix off).
|
|
456
454
|
def bare_model_name(full_ref)
|
|
457
455
|
@host_registry.bare_name(full_ref)
|
|
458
456
|
end
|
|
459
457
|
|
|
458
|
+
# The memory overlay key follows the model sent (ModelOverlay.key_for
|
|
459
|
+
# its id). A model typed as an alias used to key overlays by the alias
|
|
460
|
+
# (before aliases were applied there): that key is read when the
|
|
461
|
+
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
462
|
+
def sync_model_key!
|
|
463
|
+
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
464
|
+
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
465
|
+
fallback = typed_key == @model_key ? nil : typed_key
|
|
466
|
+
@kernel.sync_model_key!(@model_key, fallback: fallback)
|
|
467
|
+
end
|
|
468
|
+
private :sync_model_key!
|
|
469
|
+
|
|
460
470
|
# The effective model's bare name for the guardrails' models: rules;
|
|
461
471
|
# nil without a model name.
|
|
462
472
|
def guardrail_model_name
|
|
@@ -469,7 +479,7 @@ module Samagotchi
|
|
|
469
479
|
def sync_kernel_client!
|
|
470
480
|
target = @host_registry.resolve(@effective_model_name)
|
|
471
481
|
@client = target.client
|
|
472
|
-
@kernel.client = target.client if @kernel.
|
|
482
|
+
@kernel.client = target.client if @kernel.client != target.client
|
|
473
483
|
backend_for(target)
|
|
474
484
|
end
|
|
475
485
|
|
|
@@ -567,11 +577,7 @@ module Samagotchi
|
|
|
567
577
|
# A plugin's init task (chi.init): never a running turn's event.
|
|
568
578
|
return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
|
|
569
579
|
|
|
570
|
-
sink =
|
|
571
|
-
in_turn = @activity_mutex.synchronize do
|
|
572
|
-
sink = @turn_event_sink
|
|
573
|
-
@turn_running
|
|
574
|
-
end
|
|
580
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
575
581
|
card[:in_turn] = in_turn ? true : false
|
|
576
582
|
if in_turn
|
|
577
583
|
emit_event(sink, card)
|
|
@@ -762,27 +768,29 @@ module Samagotchi
|
|
|
762
768
|
|
|
763
769
|
# ── Model switching ────────────────────────────────────────────────────────
|
|
764
770
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
771
|
+
# @param typed [String, nil] the name the model was typed as when
|
|
772
|
+
# +model_name+ is a stored ref (a resumed session's model_typed)
|
|
773
|
+
# @return [String] the resolved ref the session stores (ModelRef#ref)
|
|
774
|
+
def switch_model!(model_name, persist_default: false, typed: nil)
|
|
775
|
+
# The alias is applied where the model is resolved (HostRegistry#resolve),
|
|
776
|
+
# once; checked here: an unknown host, an alias naming another host.
|
|
777
|
+
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
769
778
|
@effective_model_name = resolved
|
|
770
|
-
|
|
771
|
-
@typed_model_name = model_name
|
|
779
|
+
@typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
|
|
772
780
|
# A profile given to .new was for the starting model.
|
|
773
781
|
@given_profile = nil
|
|
774
782
|
@profile_resolution = nil
|
|
775
|
-
|
|
776
|
-
@kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
|
|
783
|
+
sync_model_key!
|
|
777
784
|
@prompt_builder.reset!
|
|
778
785
|
sync_kernel_client!
|
|
779
|
-
@client.invalidate_context_window!
|
|
786
|
+
@client.invalidate_context_window!
|
|
780
787
|
@metrics.forget_model_reports!
|
|
788
|
+
ref = effective_model_ref
|
|
781
789
|
if persist_default
|
|
782
|
-
ConfigFile.write_default_model!(
|
|
783
|
-
@default_model_name =
|
|
790
|
+
ConfigFile.write_default_model!(ref)
|
|
791
|
+
@default_model_name = ref
|
|
784
792
|
end
|
|
785
|
-
|
|
793
|
+
ref
|
|
786
794
|
end
|
|
787
795
|
|
|
788
796
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
@@ -814,53 +822,17 @@ module Samagotchi
|
|
|
814
822
|
end
|
|
815
823
|
|
|
816
824
|
# ── Reminders API ────────────────────────────────────────────────────────────
|
|
817
|
-
|
|
818
|
-
# Get and inject all due reminders into the conversation. Called at the top
|
|
819
|
-
# of run_turn (before set_turn_running(true)) so injection happens on the
|
|
820
|
-
# main thread, serially with the turn — no TOCTOU race.
|
|
821
|
-
#
|
|
822
|
-
# Returns the array of due reminder hashes (may be empty). Injects a
|
|
823
|
-
# [SYSTEM: REMINDERS DUE] message into the conversation when there are
|
|
824
|
-
# Get due reminders, inject them into the provided messages array as
|
|
825
|
-
# [SYSTEM: REMINDERS DUE], and atomically mark all as fired under one
|
|
826
|
-
# ReminderStore lock.
|
|
827
|
-
#
|
|
828
|
-
# This is the canonical method for reminder injection, called from
|
|
829
|
-
# Engine#run_turn after system prompt construction so the reminder text
|
|
830
|
-
# is never overwritten.
|
|
831
825
|
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
reminder_lines = due.map do |r|
|
|
839
|
-
" #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)"
|
|
840
|
-
end.join("\n")
|
|
841
|
-
reminder_text = "[SYSTEM: REMINDERS DUE]\n#{reminder_lines}\n[END REMINDERS]"
|
|
842
|
-
# Append as a tail message to preserve prefix KV cache. Mutating the
|
|
843
|
-
# head system prompt invalidates the cache for the entire conversation
|
|
844
|
-
# (prompt re-evaluated every interval). A tail append keeps the prefix
|
|
845
|
-
# intact — only the new reminder suffix is evaluated. Mirrors the
|
|
846
|
-
# context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
|
|
847
|
-
messages << { role: "system", content: reminder_text }
|
|
848
|
-
# Atomically mark all due as fired under one lock and clear the
|
|
849
|
-
# IdleReminders latch so the next interval can be detected.
|
|
850
|
-
@reminder_store&.mark_fired_batch(due.map { |r| r[:name] })
|
|
851
|
-
@reminders&.clear_due
|
|
852
|
-
# Also clear the REPL's due-reminder queue (#note_due_reminders).
|
|
853
|
-
# Without this, a normal-turn injection leaves a stale entry, causing
|
|
854
|
-
# the next top-of-loop synthetic turn to fire empty and duplicate output.
|
|
855
|
-
clear_due_reminder_names!
|
|
856
|
-
due
|
|
857
|
-
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.
|
|
858
831
|
|
|
859
832
|
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
860
|
-
# which
|
|
833
|
+
# which a turn would inject), regardless of the queued names
|
|
861
834
|
def reminders_due?
|
|
862
|
-
due
|
|
863
|
-
!(due.nil? || due.empty?)
|
|
835
|
+
@reminder_queue.due?
|
|
864
836
|
end
|
|
865
837
|
|
|
866
838
|
# @return [ReminderStore] the reminder store for inspection
|
|
@@ -897,9 +869,9 @@ module Samagotchi
|
|
|
897
869
|
# no turn yet (it woke after an idle exit): nil when either count is
|
|
898
870
|
# unknown or context.status is off.
|
|
899
871
|
def saved_context_status(context)
|
|
900
|
-
return nil unless context
|
|
872
|
+
return nil unless context
|
|
901
873
|
|
|
902
|
-
|
|
874
|
+
ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
903
875
|
end
|
|
904
876
|
|
|
905
877
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
@@ -964,11 +936,11 @@ module Samagotchi
|
|
|
964
936
|
event_seq: @session_observer&.event_count,
|
|
965
937
|
metrics: metrics,
|
|
966
938
|
pending_question: @question_desk.pending,
|
|
967
|
-
used_memory_names: @
|
|
939
|
+
used_memory_names: @used_memories.names,
|
|
968
940
|
preloaded_memory_names: preloaded_memory_names,
|
|
969
941
|
muted_memory_names: @muted_memory_names.dup,
|
|
970
942
|
parent_id: @session&.parent_id,
|
|
971
|
-
model_name:
|
|
943
|
+
model_name: effective_model_ref,
|
|
972
944
|
served_model: served_pair[0],
|
|
973
945
|
served_model_for: served_pair[1],
|
|
974
946
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
@@ -980,7 +952,7 @@ module Samagotchi
|
|
|
980
952
|
|
|
981
953
|
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
982
954
|
def used_memory_names
|
|
983
|
-
@
|
|
955
|
+
@used_memories.names
|
|
984
956
|
end
|
|
985
957
|
|
|
986
958
|
# @return [Array<String>] the memories hidden from this session (normalized names)
|
|
@@ -999,73 +971,6 @@ module Samagotchi
|
|
|
999
971
|
MutedMemories.muted?(name, @muted_memory_names)
|
|
1000
972
|
end
|
|
1001
973
|
|
|
1002
|
-
def add_used_memory_names(names)
|
|
1003
|
-
return if names.nil? || Array(names).empty?
|
|
1004
|
-
|
|
1005
|
-
@used_memory_mutex.synchronize do
|
|
1006
|
-
Array(names).each do |n|
|
|
1007
|
-
v = n.to_s.strip
|
|
1008
|
-
next if v.empty?
|
|
1009
|
-
next if @used_memory_names.include?(v)
|
|
1010
|
-
|
|
1011
|
-
@used_memory_names << v
|
|
1012
|
-
end
|
|
1013
|
-
end
|
|
1014
|
-
end
|
|
1015
|
-
|
|
1016
|
-
def sync_used_memories_from_session(session)
|
|
1017
|
-
return unless session && session.respond_to?(:used_memory_names)
|
|
1018
|
-
|
|
1019
|
-
add_used_memory_names(Array(session.used_memory_names))
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
def memory_name_from_tool_call(call)
|
|
1023
|
-
return nil unless call.is_a?(Hash)
|
|
1024
|
-
|
|
1025
|
-
tool = call[:name].to_s
|
|
1026
|
-
case tool
|
|
1027
|
-
when Tools::MemoryRead::NAME
|
|
1028
|
-
content = call[:content].to_s.strip
|
|
1029
|
-
return nil if content.empty?
|
|
1030
|
-
|
|
1031
|
-
# comma-separated names
|
|
1032
|
-
content.split(",").map { |s| normalize_memory_name(s) }.compact
|
|
1033
|
-
when Tools::Read::NAME
|
|
1034
|
-
path = call[:content].to_s.strip.tr("\\", "/")
|
|
1035
|
-
return nil if path.empty?
|
|
1036
|
-
return nil unless path.match?(/memories\/.+\.md\z/)
|
|
1037
|
-
|
|
1038
|
-
normalize_memory_name(path)
|
|
1039
|
-
else
|
|
1040
|
-
nil
|
|
1041
|
-
end
|
|
1042
|
-
end
|
|
1043
|
-
|
|
1044
|
-
def normalize_memory_name(raw)
|
|
1045
|
-
v = raw.to_s.strip
|
|
1046
|
-
return nil if v.empty?
|
|
1047
|
-
|
|
1048
|
-
# basename without .md, handle comma already split
|
|
1049
|
-
base = File.basename(v, ".md").strip
|
|
1050
|
-
base.empty? ? nil : base
|
|
1051
|
-
end
|
|
1052
|
-
|
|
1053
|
-
# A memory read (memory_read, or read of a memories/*.md file) as it
|
|
1054
|
-
# starts: its names join used_memory_names.
|
|
1055
|
-
# @return [Array<String>, nil] the names this call read, nil for any other event
|
|
1056
|
-
def capture_used_memory_from_event(event)
|
|
1057
|
-
return unless event.is_a?(Hash) && event[:type] == :tool_call_started
|
|
1058
|
-
|
|
1059
|
-
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1060
|
-
names = memory_name_from_tool_call(call)
|
|
1061
|
-
# A refused read of a muted memory is not a use of it.
|
|
1062
|
-
names = Array(names).reject { |n| memory_muted?(n) }
|
|
1063
|
-
return if names.empty?
|
|
1064
|
-
|
|
1065
|
-
add_used_memory_names(names)
|
|
1066
|
-
names
|
|
1067
|
-
end
|
|
1068
|
-
|
|
1069
974
|
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
1070
975
|
|
|
1071
976
|
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
@@ -1088,7 +993,7 @@ module Samagotchi
|
|
|
1088
993
|
|
|
1089
994
|
# The metrics load the session's saved records from its state dir, once.
|
|
1090
995
|
def bind_metrics(session)
|
|
1091
|
-
return unless session
|
|
996
|
+
return unless session&.id
|
|
1092
997
|
|
|
1093
998
|
@metrics.state_dir = session_state_dir
|
|
1094
999
|
@metrics.session_id = session.id
|
|
@@ -1178,6 +1083,38 @@ module Samagotchi
|
|
|
1178
1083
|
end
|
|
1179
1084
|
private :record_last_turn
|
|
1180
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
|
+
|
|
1181
1118
|
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1182
1119
|
# and tell the observers (:answer_display), after the turn_completed
|
|
1183
1120
|
# they already had: the web re-reads the answer then. A turn_completed
|
|
@@ -1231,11 +1168,7 @@ module Samagotchi
|
|
|
1231
1168
|
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
|
|
1232
1169
|
return hold_load_event(notice) && nil if @loading_plugins
|
|
1233
1170
|
|
|
1234
|
-
sink =
|
|
1235
|
-
in_turn = @activity_mutex.synchronize do
|
|
1236
|
-
sink = @turn_event_sink
|
|
1237
|
-
@turn_running
|
|
1238
|
-
end
|
|
1171
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
1239
1172
|
if anytime_thread?
|
|
1240
1173
|
announce_anytime(notice.merge(between_turns: true))
|
|
1241
1174
|
elsif current_init_task
|
|
@@ -1376,10 +1309,10 @@ module Samagotchi
|
|
|
1376
1309
|
def session=(session)
|
|
1377
1310
|
@session = session
|
|
1378
1311
|
# One session per REPL/worker process: its records carry this sid.
|
|
1379
|
-
Log.session_id = session.id if session
|
|
1312
|
+
Log.session_id = session.id if session&.id
|
|
1380
1313
|
# A woken worker's /stats and status line count the turns before it.
|
|
1381
1314
|
bind_metrics(session)
|
|
1382
|
-
|
|
1315
|
+
@used_memories.absorb(session)
|
|
1383
1316
|
end
|
|
1384
1317
|
|
|
1385
1318
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
@@ -1407,7 +1340,7 @@ module Samagotchi
|
|
|
1407
1340
|
state = @recap&.state
|
|
1408
1341
|
return nil unless state
|
|
1409
1342
|
|
|
1410
|
-
messages = @
|
|
1343
|
+
messages = @session&.messages || []
|
|
1411
1344
|
covered = state[:covered].to_i
|
|
1412
1345
|
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
|
|
1413
1346
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
@@ -1429,10 +1362,27 @@ module Samagotchi
|
|
|
1429
1362
|
self
|
|
1430
1363
|
end
|
|
1431
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
|
+
|
|
1432
1382
|
# Run a single turn with event emission.
|
|
1433
1383
|
#
|
|
1434
1384
|
# Builds the system prompt + user messages, runs the kernel loop with
|
|
1435
|
-
# event forwarding, and returns
|
|
1385
|
+
# event forwarding, and returns its LLM::ModelResult.
|
|
1436
1386
|
#
|
|
1437
1387
|
# @param session [Session] the session to operate on
|
|
1438
1388
|
# @param prompt [String] user input
|
|
@@ -1441,7 +1391,7 @@ module Samagotchi
|
|
|
1441
1391
|
# @param cancel_controller [CancellationController, nil]
|
|
1442
1392
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1443
1393
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
1444
|
-
# @return [
|
|
1394
|
+
# @return [LLM::ModelResult]
|
|
1445
1395
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
1446
1396
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
1447
1397
|
# by the agentic loop at iteration boundaries (see KernelLoop#run).
|
|
@@ -1460,283 +1410,334 @@ module Samagotchi
|
|
|
1460
1410
|
#
|
|
1461
1411
|
# An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
|
|
1462
1412
|
# the prompt is kept in the session and :turn_canceled is emitted, then the
|
|
1463
|
-
# 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
|
|
1464
1416
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1465
1417
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1466
1418
|
# one-line summary:.
|
|
1467
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,
|
|
1468
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:)
|
|
1469
1458
|
# Track the active session for recap and status snapshot.
|
|
1470
1459
|
@session = session
|
|
1471
1460
|
# status is turn state: running now, idle again before the turn's end
|
|
1472
1461
|
# is announced, so a UI reacting to that event reads the new state.
|
|
1473
1462
|
session.status = Session::STATUS_RUNNING
|
|
1474
|
-
|
|
1475
|
-
#
|
|
1476
|
-
|
|
1477
|
-
#
|
|
1478
|
-
|
|
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.
|
|
1479
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
|
+
|
|
1480
1497
|
# Ask the server for its window again each turn (one short /props GET,
|
|
1481
1498
|
# cached across the turn's generations): a restart with another -c
|
|
1482
|
-
# between turns raises no error that would drop the cache.
|
|
1483
|
-
|
|
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!
|
|
1484
1504
|
refresh_profile!
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
1492
1523
|
end
|
|
1493
|
-
# The gate's context: who queued this turn, and git asked afresh.
|
|
1494
|
-
@guardrail_wiring.begin_turn(origin)
|
|
1495
1524
|
|
|
1496
|
-
|
|
1497
|
-
#
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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.
|
|
1504
1605
|
begin
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1514
|
-
emit_event(on_event, with_origin.call(turn_started))
|
|
1515
|
-
raise image_error if image_error
|
|
1516
|
-
|
|
1517
|
-
# Before anything of the turn is kept or a reminder is used up.
|
|
1518
|
-
vision = turn_vision(session)
|
|
1519
|
-
@kernel.vision = vision if @kernel.respond_to?(:vision=)
|
|
1520
|
-
@kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
|
|
1521
|
-
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1522
|
-
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1523
|
-
@kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
|
|
1524
|
-
announce_thinking_level(*@turn_thinking)
|
|
1525
|
-
refuse_images!(vision) unless image_refs.empty?
|
|
1526
|
-
announce_guardrail_failures(on_event)
|
|
1527
|
-
# Plugins' slow setup that brings tools (an MCP server's first
|
|
1528
|
-
# start): the turn waits for it here, before the system prompt
|
|
1529
|
-
# declares the tools; a Ctrl-C ends the wait (the turn is cancelled
|
|
1530
|
-
# at its first request).
|
|
1531
|
-
start_init_tasks!
|
|
1532
|
-
await_init_tasks(effective_controller, on_event)
|
|
1533
|
-
# Plugins' tool sets that changed since the last turn.
|
|
1534
|
-
apply_staged_tools!
|
|
1535
|
-
|
|
1536
|
-
# Fire :session_start on the very first turn
|
|
1537
|
-
if @first_turn
|
|
1538
|
-
@hooks.fire(:session_start, { type: :session_start, session_id: session.id })
|
|
1539
|
-
@first_turn = false
|
|
1540
|
-
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?
|
|
1541
1614
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
if
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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 }]
|
|
1564
1640
|
end
|
|
1641
|
+
end
|
|
1565
1642
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
|
1572
1689
|
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
# tag those dumps with this turn's model, not the last native one.
|
|
1579
|
-
@kernel.current_model_name = bare_for_backend if @kernel.respond_to?(:current_model_name=)
|
|
1580
|
-
|
|
1581
|
-
result = backend.complete(
|
|
1582
|
-
messages: messages,
|
|
1583
|
-
max_iterations: max_iterations,
|
|
1584
|
-
on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
|
|
1585
|
-
cancel_controller: effective_controller,
|
|
1586
|
-
model_name: bare_for_backend,
|
|
1587
|
-
max_tool_output_chars: max_tool_output_chars,
|
|
1588
|
-
pending_input: turn_drain(pending_input)
|
|
1589
|
-
)
|
|
1590
|
-
|
|
1591
|
-
# Persist deduped used memories onto the session for Web + reload.
|
|
1592
|
-
begin
|
|
1593
|
-
session.used_memory_names = used_memory_names
|
|
1594
|
-
rescue StandardError
|
|
1595
|
-
nil
|
|
1596
|
-
end
|
|
1597
|
-
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
1598
|
-
# even without a tool_call event if the preload was the only addition).
|
|
1599
|
-
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
1600
|
-
if used_memory_names.any?
|
|
1601
|
-
begin
|
|
1602
|
-
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
1603
|
-
rescue StandardError
|
|
1604
|
-
nil
|
|
1605
|
-
end
|
|
1606
|
-
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
|
|
1607
1695
|
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
# the event log: a snapshot taken meanwhile (the Bridge's) shows the
|
|
1613
|
-
# turn either in progress or in the messages, never both or neither.
|
|
1614
|
-
# A turn that ran out of iterations ends at its tool results, so a
|
|
1615
|
-
# continue resumes from them rather than after a made-up reply.
|
|
1616
|
-
resumable = result.respond_to?(:resumable?) && result.resumable?
|
|
1617
|
-
# Nothing visible (the native loop: no text; the chat loop: its
|
|
1618
|
-
# placeholder text) is a turn the model should know ended that way.
|
|
1619
|
-
empty = !canceled && !resumable &&
|
|
1620
|
-
(response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
|
|
1621
|
-
# after_turn hooks run below and may present the answer (the web
|
|
1622
|
-
# holds its pop until it knows).
|
|
1623
|
-
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1624
|
-
synchronize_events do
|
|
1625
|
-
if empty
|
|
1626
|
-
# The placeholder is for the UIs (a new array: it must not leak
|
|
1627
|
-
# into the result); the note is for the model, so it goes on the
|
|
1628
|
-
# result's conversation too, which the REPL keeps as-is.
|
|
1629
|
-
# A retry's nudge at the tail goes: this note says it all.
|
|
1630
|
-
note = TurnNote.empty
|
|
1631
|
-
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1632
|
-
saved = TurnNote.without_trailing(conversation || session.messages)
|
|
1633
|
-
saved << { role: "model", content: "[No response]" } if response.strip.empty?
|
|
1634
|
-
replace_session_messages(session, saved + [note])
|
|
1635
|
-
conversation << note if conversation
|
|
1636
|
-
elsif canceled && conversation
|
|
1637
|
-
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
|
|
1638
|
-
stopped_by: effective_controller.detail,
|
|
1639
|
-
shown: TurnNote.interrupted_tail?(conversation),
|
|
1640
|
-
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1641
|
-
replace_session_messages(session, conversation)
|
|
1642
|
-
elsif conversation
|
|
1643
|
-
replace_session_messages(session, conversation)
|
|
1644
|
-
end
|
|
1645
|
-
session.status = Session::STATUS_IDLE
|
|
1646
|
-
record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
|
|
1647
|
-
if canceled
|
|
1648
|
-
emit_event(on_event, with_origin.call({
|
|
1649
|
-
type: :turn_canceled,
|
|
1650
|
-
cancellation_reason: result.cancellation_reason,
|
|
1651
|
-
duration_ms: (turn_seconds.call * 1000).round
|
|
1652
|
-
}))
|
|
1653
|
-
else
|
|
1654
|
-
# For a client that attaches later (session_state_snapshot).
|
|
1655
|
-
@last_context_status = result.context_status.dup if result.context_status
|
|
1656
|
-
emit_event(on_event, with_origin.call({
|
|
1657
|
-
type: :turn_completed,
|
|
1658
|
-
result: result,
|
|
1659
|
-
turn_summary: turn_summary(result),
|
|
1660
|
-
display_pending: display_pending
|
|
1661
|
-
}))
|
|
1662
|
-
end
|
|
1663
|
-
end
|
|
1664
|
-
@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
|
|
1665
1700
|
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
@hooks.fire(:after_turn, after_turn)
|
|
1674
|
-
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
|
|
1675
1708
|
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
|
1678
1713
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if messages
|
|
1686
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
|
|
1687
|
-
running_tasks: Tools::TaskRuntime.running_created_in(messages))
|
|
1688
|
-
replace_session_messages(session, TurnNote.replace_trailing(messages, note))
|
|
1689
|
-
end
|
|
1690
|
-
session.status = Session::STATUS_IDLE
|
|
1691
|
-
record_last_turn(session, "canceled", turn_seconds.call, origin)
|
|
1692
|
-
emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c,
|
|
1693
|
-
duration_ms: (turn_seconds.call * 1000).round }))
|
|
1694
|
-
end
|
|
1695
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
1696
|
-
raise
|
|
1697
|
-
rescue StandardError => e
|
|
1698
|
-
# Keep what the turn got to (the prompt plus the loop's completed
|
|
1699
|
-
# tool iterations) like a cancel does, and save it: a worker exits
|
|
1700
|
-
# after a failed turn. The REPL still rolls back to its checkpoint.
|
|
1701
|
-
kept = e.respond_to?(:partial_conversation) && e.partial_conversation.is_a?(Array) ? e.partial_conversation : messages
|
|
1702
|
-
# The model reads why on its next turn (a UI that rolls the turn back
|
|
1703
|
-
# leaves its own note, TurnFlow#prompt_turn_failed). Nothing when the
|
|
1704
|
-
# turn never reached the model (kept is nil).
|
|
1705
|
-
if kept
|
|
1706
|
-
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
1707
|
-
kept = TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: continue))
|
|
1708
|
-
end
|
|
1709
|
-
replace_session_messages(session, kept) if kept
|
|
1710
|
-
session.status = Session::STATUS_IDLE
|
|
1711
|
-
record_last_turn(session, "failed", turn_seconds.call, origin)
|
|
1712
|
-
begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
|
|
1713
|
-
failed = { type: :turn_failed, error_class: e.class.name, message: e.message,
|
|
1714
|
-
duration_ms: (turn_seconds.call * 1000).round }
|
|
1715
|
-
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1716
|
-
if e.is_a?(LLM::ProviderError)
|
|
1717
|
-
failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
|
|
1718
|
-
end
|
|
1719
|
-
emit_event(on_event, with_origin.call(failed))
|
|
1720
|
-
@metrics.persist(state_dir: session_state_dir)
|
|
1721
|
-
raise
|
|
1722
|
-
ensure
|
|
1723
|
-
# A completed turn is activity: release the turn flag and advance the
|
|
1724
|
-
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1725
|
-
# treats the just-finished turn as activity and re-arms its window.
|
|
1726
|
-
# Always runs, even if an exception occurred.
|
|
1727
|
-
set_turn_running(false)
|
|
1728
|
-
Client.swap_probe_cancel(probe_cancel_before)
|
|
1729
|
-
left = @activity_mutex.synchronize do
|
|
1730
|
-
@active_cancel_controller = nil
|
|
1731
|
-
@turn_event_sink = nil
|
|
1732
|
-
@steers.tap { @steers = [] }
|
|
1733
|
-
end
|
|
1734
|
-
log_dropped_steers(left, "turn_ended")
|
|
1735
|
-
record_activity
|
|
1736
|
-
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1737
|
-
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)
|
|
1738
1720
|
end
|
|
1739
|
-
|
|
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
|
|
1740
1741
|
|
|
1741
1742
|
# [refs, nil] for a turn's images, or [[], error] when one can't be used
|
|
1742
1743
|
# (the turn then fails right after :turn_started).
|
|
@@ -1795,7 +1796,7 @@ module Samagotchi
|
|
|
1795
1796
|
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
1796
1797
|
|
|
1797
1798
|
client = target.client
|
|
1798
|
-
props = client.
|
|
1799
|
+
props = client.cached_server_props(model: target.bare_model)
|
|
1799
1800
|
return unless Thinking.effort_ignored?(level, props)
|
|
1800
1801
|
|
|
1801
1802
|
thinking_notice_once(:unsupported, target, :info,
|
|
@@ -1920,8 +1921,16 @@ module Samagotchi
|
|
|
1920
1921
|
|
|
1921
1922
|
private
|
|
1922
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
|
+
|
|
1923
1932
|
def replace_session_messages(session, messages)
|
|
1924
|
-
|
|
1933
|
+
session.messages = messages
|
|
1925
1934
|
end
|
|
1926
1935
|
|
|
1927
1936
|
# Pick the loop for a target; the chat loop is pointed at the target
|
|
@@ -2037,11 +2046,9 @@ module Samagotchi
|
|
|
2037
2046
|
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
|
|
2038
2047
|
hook_ask_user(question, options, header, allow_freeform, hook)
|
|
2039
2048
|
},
|
|
2040
|
-
# Nothing to cancel while the Engine is still being built (a
|
|
2041
|
-
# server that starts as its plugin loads).
|
|
2042
2049
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2043
2050
|
cancelled: lambda {
|
|
2044
|
-
(task = current_init_task) ? task.cancelled? :
|
|
2051
|
+
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|
|
2045
2052
|
},
|
|
2046
2053
|
card: ->(**card) { show_card(**card) },
|
|
2047
2054
|
steer: ->(text, label) { steer(text, source: hook_source(label)) },
|
|
@@ -2115,7 +2122,7 @@ module Samagotchi
|
|
|
2115
2122
|
@auto_turn_callback = auto_turn_callback
|
|
2116
2123
|
IdleReminders.new(
|
|
2117
2124
|
engine: self,
|
|
2118
|
-
|
|
2125
|
+
queue: @reminder_queue,
|
|
2119
2126
|
callback: @auto_turn_callback
|
|
2120
2127
|
)
|
|
2121
2128
|
end
|
|
@@ -2177,7 +2184,7 @@ module Samagotchi
|
|
|
2177
2184
|
def emit_event(on_event, event)
|
|
2178
2185
|
# Capture used memories synchronously in the turn thread.
|
|
2179
2186
|
read_names = begin
|
|
2180
|
-
|
|
2187
|
+
@used_memories.capture(event, muted: @muted_memory_names)
|
|
2181
2188
|
rescue StandardError
|
|
2182
2189
|
nil
|
|
2183
2190
|
end
|
|
@@ -2209,7 +2216,7 @@ module Samagotchi
|
|
|
2209
2216
|
adapter = nil
|
|
2210
2217
|
if target.entry.chat?
|
|
2211
2218
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2212
|
-
client = nil if adapter.
|
|
2219
|
+
client = nil if adapter.remote?
|
|
2213
2220
|
end
|
|
2214
2221
|
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
|
|
2215
2222
|
rescue StandardError
|
|
@@ -2227,9 +2234,9 @@ module Samagotchi
|
|
|
2227
2234
|
client = target.client
|
|
2228
2235
|
if target.entry.chat?
|
|
2229
2236
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2230
|
-
return [nil, nil] if adapter.
|
|
2237
|
+
return [nil, nil] if adapter.remote?
|
|
2231
2238
|
end
|
|
2232
|
-
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2239
|
+
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2233
2240
|
served ? [served, asked] : [nil, nil]
|
|
2234
2241
|
rescue StandardError
|
|
2235
2242
|
[nil, nil]
|
|
@@ -2256,7 +2263,7 @@ module Samagotchi
|
|
|
2256
2263
|
# prompt format and parser, and the system prompts built for the old one.
|
|
2257
2264
|
def apply_profile(resolution)
|
|
2258
2265
|
@prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
|
|
2259
|
-
@kernel.use_profile!(resolution)
|
|
2266
|
+
@kernel.use_profile!(resolution)
|
|
2260
2267
|
resolution
|
|
2261
2268
|
end
|
|
2262
2269
|
|