samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- 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/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- 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 +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- 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 +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -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,39 @@ 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
|
|
214
|
-
# Keep kernel client in sync with active host via setter
|
|
215
|
-
@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
|
+
@kernel.muted_memory_names = @muted_memory_names
|
|
225
226
|
# ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
|
|
226
|
-
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
227
|
+
@kernel.question_handler = proc { |payload| request_question(payload) }
|
|
227
228
|
# Every tool call asks this gate first. The kernel is never rebuilt, so
|
|
228
229
|
# it holds across model switches.
|
|
229
230
|
self.guardrail_state_dir = Session.default_state_dir
|
|
230
231
|
# list_sessions and send_note speak for whichever session runs now.
|
|
231
|
-
@kernel.peers = PeerView.new(self)
|
|
232
|
-
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
232
|
+
@kernel.peers = PeerView.new(self)
|
|
233
|
+
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
233
234
|
# What a hook can do beyond reading its event (event[:notify],
|
|
234
235
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
235
236
|
@hooks.runtime = hook_runtime
|
|
@@ -244,29 +245,18 @@ module Samagotchi
|
|
|
244
245
|
@session = nil
|
|
245
246
|
@session_observer = SessionObserver.new
|
|
246
247
|
@metrics = SessionMetrics.new
|
|
247
|
-
@
|
|
248
|
-
@used_memory_mutex = Monitor.new
|
|
248
|
+
@used_memories = UsedMemories.new
|
|
249
249
|
# Hydrate from resumed session if present
|
|
250
|
-
if @resume_session
|
|
251
|
-
@
|
|
250
|
+
if @resume_session
|
|
251
|
+
@used_memories.absorb(@resume_session)
|
|
252
252
|
@session = @resume_session
|
|
253
253
|
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 = []
|
|
254
|
+
# The idle subsystems' (session recap + reminders) inactivity clock
|
|
255
|
+
# starts now, after the plugins loaded. `record_activity` is the
|
|
256
|
+
# single seam every UI calls (run_turn itself, the REPL on keystrokes
|
|
257
|
+
# and after a reminder turn), so the idle layer's clock is identical
|
|
258
|
+
# across UIs.
|
|
259
|
+
@turn_state.restart_clock!
|
|
270
260
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
271
261
|
session_target: -> { session_model_recap_target },
|
|
272
262
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -296,37 +286,29 @@ module Samagotchi
|
|
|
296
286
|
backend_for(@host_registry.resolve(@effective_model_name))
|
|
297
287
|
end
|
|
298
288
|
|
|
299
|
-
# Record that activity happened (user input or a completed turn)
|
|
300
|
-
#
|
|
301
|
-
#
|
|
289
|
+
# Record that activity happened (user input or a completed turn): the
|
|
290
|
+
# idle jobs' seam (TurnState#record_activity). Each call advances both
|
|
291
|
+
# the last-activity timestamp and the activity sequence.
|
|
302
292
|
# @param now [Float, nil] injectable monotonic time (defaults to now)
|
|
303
293
|
def record_activity(now = nil)
|
|
304
|
-
@
|
|
305
|
-
@last_activity_at = now ? now.to_f : monotonic_now
|
|
306
|
-
@activity_seq += 1
|
|
307
|
-
end
|
|
294
|
+
@turn_state.record_activity(now)
|
|
308
295
|
end
|
|
309
296
|
|
|
310
297
|
# @return [Float] monotonic seconds of the last recorded activity
|
|
311
298
|
def last_activity_at
|
|
312
|
-
@
|
|
299
|
+
@turn_state.last_activity_at
|
|
313
300
|
end
|
|
314
301
|
|
|
315
302
|
# @return [Integer] monotonically-increasing activity counter (advanced by
|
|
316
303
|
# #record_activity; lets the idle detector summarize once per idle window)
|
|
317
304
|
def activity_seq
|
|
318
|
-
@
|
|
305
|
+
@turn_state.activity_seq
|
|
319
306
|
end
|
|
320
307
|
|
|
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
|
|
308
|
+
# @return [Boolean] true while a turn is in flight (the idle jobs never
|
|
309
|
+
# fire, nor render, while the model is generating)
|
|
328
310
|
def turn_running?
|
|
329
|
-
@
|
|
311
|
+
@turn_state.running?
|
|
330
312
|
end
|
|
331
313
|
|
|
332
314
|
# Put +text+ into the running turn, as a UI's steering does: it joins the
|
|
@@ -337,15 +319,7 @@ module Samagotchi
|
|
|
337
319
|
# the turn ends first, it is dropped (logged).
|
|
338
320
|
# @return [Boolean] whether it was queued
|
|
339
321
|
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
|
|
322
|
+
@turn_state.steer(text, source: source)
|
|
349
323
|
end
|
|
350
324
|
|
|
351
325
|
# The drain a turn's loop gets: the caller's lines (a UI's steering; nil
|
|
@@ -362,11 +336,7 @@ module Samagotchi
|
|
|
362
336
|
private :turn_drain
|
|
363
337
|
|
|
364
338
|
def take_steers(drop)
|
|
365
|
-
steers = @
|
|
366
|
-
taken = @steers
|
|
367
|
-
@steers = []
|
|
368
|
-
taken
|
|
369
|
-
end
|
|
339
|
+
steers = @turn_state.take_steers
|
|
370
340
|
return steers unless drop
|
|
371
341
|
|
|
372
342
|
log_dropped_steers(steers, "answered")
|
|
@@ -383,41 +353,37 @@ module Samagotchi
|
|
|
383
353
|
|
|
384
354
|
# @return [Array<String>] reminder names queued for a synthetic REPL turn
|
|
385
355
|
def due_reminder_names
|
|
386
|
-
@
|
|
356
|
+
@reminder_queue.pending_names
|
|
387
357
|
end
|
|
388
358
|
|
|
389
359
|
# Queue reminder names for a synthetic REPL turn (IdleReminders callback).
|
|
390
360
|
def note_due_reminders(names)
|
|
391
|
-
@
|
|
361
|
+
@reminder_queue.note_pending(names)
|
|
392
362
|
end
|
|
393
363
|
|
|
394
364
|
def clear_due_reminder_names!
|
|
395
|
-
@
|
|
365
|
+
@reminder_queue.clear_pending!
|
|
396
366
|
end
|
|
397
367
|
|
|
398
368
|
# @return [CancellationController, nil] active turn's cancellation controller
|
|
399
369
|
def active_cancel_controller
|
|
400
|
-
@
|
|
370
|
+
@turn_state.controller
|
|
401
371
|
end
|
|
402
372
|
|
|
403
373
|
# Cancel the currently running turn, if any.
|
|
404
374
|
# @param reason [Symbol] cancellation reason
|
|
405
375
|
# @return [Boolean] whether a cancellation was triggered
|
|
406
376
|
def cancel_current_turn!(reason = :manual)
|
|
407
|
-
|
|
408
|
-
return false unless ctrl
|
|
409
|
-
|
|
410
|
-
ctrl.cancel!(reason)
|
|
377
|
+
@turn_state.cancel!(reason)
|
|
411
378
|
end
|
|
412
379
|
|
|
413
380
|
# Snapshot the current session messages as a JSON string for the idle
|
|
414
|
-
# recap.
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
# session.messages.
|
|
381
|
+
# recap. The array is never mutated in place (a turn or a note replaces
|
|
382
|
+
# it), so the reference read here is a consistent snapshot; a dup'd
|
|
383
|
+
# copy is serialized. Never mutates session.messages.
|
|
418
384
|
# @return [String] JSON array of the messages
|
|
419
385
|
def messages_json_for_recap
|
|
420
|
-
snapshot = @
|
|
386
|
+
snapshot = @session&.messages
|
|
421
387
|
return "[]" if snapshot.nil?
|
|
422
388
|
|
|
423
389
|
JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
|
|
@@ -453,7 +419,7 @@ module Samagotchi
|
|
|
453
419
|
def store_model!(session)
|
|
454
420
|
ref = effective_model_ref
|
|
455
421
|
session.model_name = ref
|
|
456
|
-
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
|
|
422
|
+
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
|
|
457
423
|
session
|
|
458
424
|
end
|
|
459
425
|
|
|
@@ -495,7 +461,7 @@ module Samagotchi
|
|
|
495
461
|
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
496
462
|
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
497
463
|
fallback = typed_key == @model_key ? nil : typed_key
|
|
498
|
-
@kernel.sync_model_key!(@model_key, fallback: fallback)
|
|
464
|
+
@kernel.sync_model_key!(@model_key, fallback: fallback)
|
|
499
465
|
end
|
|
500
466
|
private :sync_model_key!
|
|
501
467
|
|
|
@@ -511,7 +477,7 @@ module Samagotchi
|
|
|
511
477
|
def sync_kernel_client!
|
|
512
478
|
target = @host_registry.resolve(@effective_model_name)
|
|
513
479
|
@client = target.client
|
|
514
|
-
@kernel.client = target.client if @kernel.
|
|
480
|
+
@kernel.client = target.client if @kernel.client != target.client
|
|
515
481
|
backend_for(target)
|
|
516
482
|
end
|
|
517
483
|
|
|
@@ -609,11 +575,7 @@ module Samagotchi
|
|
|
609
575
|
# A plugin's init task (chi.init): never a running turn's event.
|
|
610
576
|
return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
|
|
611
577
|
|
|
612
|
-
sink =
|
|
613
|
-
in_turn = @activity_mutex.synchronize do
|
|
614
|
-
sink = @turn_event_sink
|
|
615
|
-
@turn_running
|
|
616
|
-
end
|
|
578
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
617
579
|
card[:in_turn] = in_turn ? true : false
|
|
618
580
|
if in_turn
|
|
619
581
|
emit_event(sink, card)
|
|
@@ -819,7 +781,7 @@ module Samagotchi
|
|
|
819
781
|
sync_model_key!
|
|
820
782
|
@prompt_builder.reset!
|
|
821
783
|
sync_kernel_client!
|
|
822
|
-
@client.invalidate_context_window!
|
|
784
|
+
@client.invalidate_context_window!
|
|
823
785
|
@metrics.forget_model_reports!
|
|
824
786
|
ref = effective_model_ref
|
|
825
787
|
if persist_default
|
|
@@ -858,53 +820,17 @@ module Samagotchi
|
|
|
858
820
|
end
|
|
859
821
|
|
|
860
822
|
# ── 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
823
|
#
|
|
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
|
|
824
|
+
# Each turn injects the reminders due then (#turn_messages,
|
|
825
|
+
# ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
|
|
826
|
+
# finds them due and its callback queues their names
|
|
827
|
+
# (#note_due_reminders); the worker and the REPL run a reminder turn
|
|
828
|
+
# (a continue turn) for them.
|
|
902
829
|
|
|
903
830
|
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
904
|
-
# which
|
|
831
|
+
# which a turn would inject), regardless of the queued names
|
|
905
832
|
def reminders_due?
|
|
906
|
-
due
|
|
907
|
-
!(due.nil? || due.empty?)
|
|
833
|
+
@reminder_queue.due?
|
|
908
834
|
end
|
|
909
835
|
|
|
910
836
|
# @return [ReminderStore] the reminder store for inspection
|
|
@@ -941,9 +867,9 @@ module Samagotchi
|
|
|
941
867
|
# no turn yet (it woke after an idle exit): nil when either count is
|
|
942
868
|
# unknown or context.status is off.
|
|
943
869
|
def saved_context_status(context)
|
|
944
|
-
return nil unless context
|
|
870
|
+
return nil unless context
|
|
945
871
|
|
|
946
|
-
|
|
872
|
+
ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
947
873
|
end
|
|
948
874
|
|
|
949
875
|
# The effective model is on a chat host (api: openai), whose loop uses
|
|
@@ -1008,7 +934,7 @@ module Samagotchi
|
|
|
1008
934
|
event_seq: @session_observer&.event_count,
|
|
1009
935
|
metrics: metrics,
|
|
1010
936
|
pending_question: @question_desk.pending,
|
|
1011
|
-
used_memory_names: @
|
|
937
|
+
used_memory_names: @used_memories.names,
|
|
1012
938
|
preloaded_memory_names: preloaded_memory_names,
|
|
1013
939
|
muted_memory_names: @muted_memory_names.dup,
|
|
1014
940
|
parent_id: @session&.parent_id,
|
|
@@ -1024,7 +950,7 @@ module Samagotchi
|
|
|
1024
950
|
|
|
1025
951
|
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
1026
952
|
def used_memory_names
|
|
1027
|
-
@
|
|
953
|
+
@used_memories.names
|
|
1028
954
|
end
|
|
1029
955
|
|
|
1030
956
|
# @return [Array<String>] the memories hidden from this session (normalized names)
|
|
@@ -1039,77 +965,6 @@ module Samagotchi
|
|
|
1039
965
|
@prompt_builder.preloaded_memory_names
|
|
1040
966
|
end
|
|
1041
967
|
|
|
1042
|
-
def memory_muted?(name)
|
|
1043
|
-
MutedMemories.muted?(name, @muted_memory_names)
|
|
1044
|
-
end
|
|
1045
|
-
|
|
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
968
|
# ── Guardrails ─────────────────────────────────────────────────────────────
|
|
1114
969
|
|
|
1115
970
|
# Who can answer an approval: :repl, :worker or :non_interactive (the
|
|
@@ -1132,7 +987,7 @@ module Samagotchi
|
|
|
1132
987
|
|
|
1133
988
|
# The metrics load the session's saved records from its state dir, once.
|
|
1134
989
|
def bind_metrics(session)
|
|
1135
|
-
return unless session
|
|
990
|
+
return unless session&.id
|
|
1136
991
|
|
|
1137
992
|
@metrics.state_dir = session_state_dir
|
|
1138
993
|
@metrics.session_id = session.id
|
|
@@ -1222,6 +1077,38 @@ module Samagotchi
|
|
|
1222
1077
|
end
|
|
1223
1078
|
private :record_last_turn
|
|
1224
1079
|
|
|
1080
|
+
# How every turn ends (completed, canceled, failed): the session gets
|
|
1081
|
+
# what the turn kept, goes idle and records the ending, and the end
|
|
1082
|
+
# event is announced, as one step of the event log (a snapshot taken
|
|
1083
|
+
# meanwhile, the Bridge's, shows the turn either in progress or in the
|
|
1084
|
+
# messages, never both or neither). Then the metrics are persisted.
|
|
1085
|
+
# The block runs in that step with the turn's seconds, once, and
|
|
1086
|
+
# returns [the messages to keep (nil: leave the session's), the end
|
|
1087
|
+
# event]. +save:+ also saves the session (a failed turn: a worker exits
|
|
1088
|
+
# after it). The disk writes come after the step: every emitter waits
|
|
1089
|
+
# for the event lock.
|
|
1090
|
+
def end_turn(turn, outcome, save: false)
|
|
1091
|
+
seconds = turn.elapsed
|
|
1092
|
+
synchronize_events do
|
|
1093
|
+
kept, event = yield(seconds)
|
|
1094
|
+
replace_session_messages(turn.session, kept) if kept
|
|
1095
|
+
turn.session.status = Session::STATUS_IDLE
|
|
1096
|
+
record_last_turn(turn.session, outcome, seconds, turn.origin)
|
|
1097
|
+
emit_event(turn.on_event, turn.tag(event))
|
|
1098
|
+
end
|
|
1099
|
+
turn.ended = true
|
|
1100
|
+
save_quietly(turn.session) if save
|
|
1101
|
+
@metrics.persist(state_dir: session_state_dir)
|
|
1102
|
+
end
|
|
1103
|
+
private :end_turn
|
|
1104
|
+
|
|
1105
|
+
def save_quietly(session)
|
|
1106
|
+
session.save(state_dir: session_state_dir)
|
|
1107
|
+
rescue StandardError
|
|
1108
|
+
nil
|
|
1109
|
+
end
|
|
1110
|
+
private :save_quietly
|
|
1111
|
+
|
|
1225
1112
|
# Keep what the after_turn hooks presented as the answer's `display`
|
|
1226
1113
|
# and tell the observers (:answer_display), after the turn_completed
|
|
1227
1114
|
# they already had: the web re-reads the answer then. A turn_completed
|
|
@@ -1275,11 +1162,7 @@ module Samagotchi
|
|
|
1275
1162
|
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
|
|
1276
1163
|
return hold_load_event(notice) && nil if @loading_plugins
|
|
1277
1164
|
|
|
1278
|
-
sink =
|
|
1279
|
-
in_turn = @activity_mutex.synchronize do
|
|
1280
|
-
sink = @turn_event_sink
|
|
1281
|
-
@turn_running
|
|
1282
|
-
end
|
|
1165
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
1283
1166
|
if anytime_thread?
|
|
1284
1167
|
announce_anytime(notice.merge(between_turns: true))
|
|
1285
1168
|
elsif current_init_task
|
|
@@ -1356,8 +1239,8 @@ module Samagotchi
|
|
|
1356
1239
|
|
|
1357
1240
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1358
1241
|
# @return [Hash] normalized answer
|
|
1359
|
-
def answer_question(id:, selected:, freeform: nil)
|
|
1360
|
-
@question_desk.answer(id: id, selected: selected, freeform: freeform)
|
|
1242
|
+
def answer_question(id:, selected:, freeform: nil, client_id: nil)
|
|
1243
|
+
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id)
|
|
1361
1244
|
end
|
|
1362
1245
|
|
|
1363
1246
|
def set_question_sync_handler(&block)
|
|
@@ -1420,10 +1303,10 @@ module Samagotchi
|
|
|
1420
1303
|
def session=(session)
|
|
1421
1304
|
@session = session
|
|
1422
1305
|
# One session per REPL/worker process: its records carry this sid.
|
|
1423
|
-
Log.session_id = session.id if session
|
|
1306
|
+
Log.session_id = session.id if session&.id
|
|
1424
1307
|
# A woken worker's /stats and status line count the turns before it.
|
|
1425
1308
|
bind_metrics(session)
|
|
1426
|
-
|
|
1309
|
+
@used_memories.absorb(session)
|
|
1427
1310
|
end
|
|
1428
1311
|
|
|
1429
1312
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
@@ -1451,7 +1334,7 @@ module Samagotchi
|
|
|
1451
1334
|
state = @recap&.state
|
|
1452
1335
|
return nil unless state
|
|
1453
1336
|
|
|
1454
|
-
messages = @
|
|
1337
|
+
messages = @session&.messages || []
|
|
1455
1338
|
covered = state[:covered].to_i
|
|
1456
1339
|
turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
|
|
1457
1340
|
{ text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
|
|
@@ -1473,10 +1356,27 @@ module Samagotchi
|
|
|
1473
1356
|
self
|
|
1474
1357
|
end
|
|
1475
1358
|
|
|
1359
|
+
# One turn's values, for #run_turn and its endings. Per turn and
|
|
1360
|
+
# single-threaded: the cross-thread turn state (the cancel slot, the
|
|
1361
|
+
# sink, steers) stays on the Engine.
|
|
1362
|
+
# +ended+: the end event is out (what follows is post-turn work).
|
|
1363
|
+
# +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
|
|
1364
|
+
# set on the kernel in #generate.
|
|
1365
|
+
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1366
|
+
:settings) do
|
|
1367
|
+
# The turn's boundary events carry the origin only when there is one,
|
|
1368
|
+
# so payloads stay unchanged for callers that don't pass it.
|
|
1369
|
+
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
1370
|
+
|
|
1371
|
+
# Seconds since the turn started (the cancel note says how long it ran).
|
|
1372
|
+
def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
1373
|
+
end
|
|
1374
|
+
private_constant :Turn
|
|
1375
|
+
|
|
1476
1376
|
# Run a single turn with event emission.
|
|
1477
1377
|
#
|
|
1478
1378
|
# Builds the system prompt + user messages, runs the kernel loop with
|
|
1479
|
-
# event forwarding, and returns
|
|
1379
|
+
# event forwarding, and returns its LLM::ModelResult.
|
|
1480
1380
|
#
|
|
1481
1381
|
# @param session [Session] the session to operate on
|
|
1482
1382
|
# @param prompt [String] user input
|
|
@@ -1485,7 +1385,7 @@ module Samagotchi
|
|
|
1485
1385
|
# @param cancel_controller [CancellationController, nil]
|
|
1486
1386
|
# @param max_tool_output_chars [Integer, nil] per-output char cap for the
|
|
1487
1387
|
# :tool_call_completed event's `output:` (nil → max_tool_output_chars)
|
|
1488
|
-
# @return [
|
|
1388
|
+
# @return [LLM::ModelResult]
|
|
1489
1389
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
1490
1390
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
1491
1391
|
# by the agentic loop at iteration boundaries (see KernelLoop#run).
|
|
@@ -1504,283 +1404,365 @@ module Samagotchi
|
|
|
1504
1404
|
#
|
|
1505
1405
|
# An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
|
|
1506
1406
|
# 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.
|
|
1407
|
+
# Interrupt is re-raised so the caller still decides whether to exit. One
|
|
1408
|
+
# after the turn's end event (in its after_turn/session_end hooks) is
|
|
1409
|
+
# only re-raised: the turn already ended; so is any error there. Any
|
|
1508
1410
|
# other error (e.g. an LLM::ProviderError) emits :turn_failed and
|
|
1509
1411
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1510
1412
|
# one-line summary:.
|
|
1511
1413
|
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
1414
|
images: [])
|
|
1415
|
+
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1416
|
+
continue: continue)
|
|
1417
|
+
begin
|
|
1418
|
+
# A Stop cuts the /props probes this thread makes for the turn
|
|
1419
|
+
# (window, served model, vision) instead of waiting their timeout.
|
|
1420
|
+
probe_cancel_before = Client.swap_probe_cancel(turn.controller)
|
|
1421
|
+
return stopped_before_model(turn) unless prepare_turn(turn, continue ? [] : images)
|
|
1422
|
+
|
|
1423
|
+
result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
|
|
1424
|
+
pending_input: pending_input)
|
|
1425
|
+
publish_used_memories(session, on_event)
|
|
1426
|
+
complete_turn(turn, result)
|
|
1427
|
+
result
|
|
1428
|
+
rescue Interrupt
|
|
1429
|
+
# After the end event (in the after_turn/session_end hooks) the
|
|
1430
|
+
# turn is over: no second ending, the answer stays.
|
|
1431
|
+
raise if turn.ended
|
|
1432
|
+
|
|
1433
|
+
turn.controller.cancel!(:ctrl_c)
|
|
1434
|
+
end_turn(turn, "canceled") do |seconds|
|
|
1435
|
+
[ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
|
|
1436
|
+
end
|
|
1437
|
+
raise
|
|
1438
|
+
rescue StandardError => e
|
|
1439
|
+
# After the end event (post-turn work) likewise: logged and
|
|
1440
|
+
# re-raised, no turn_failed over the turn that ended.
|
|
1441
|
+
if turn.ended
|
|
1442
|
+
Log.warn(:turn, "post_turn_error", error: "#{e.class}: #{e.message}")
|
|
1443
|
+
raise
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
# Keep what the turn got to (the prompt plus the loop's completed
|
|
1447
|
+
# tool iterations) like a cancel does, and save it: a worker exits
|
|
1448
|
+
# after a failed turn. The REPL still rolls back to its checkpoint.
|
|
1449
|
+
end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
|
|
1450
|
+
raise
|
|
1451
|
+
ensure
|
|
1452
|
+
release_turn(probe_cancel_before)
|
|
1453
|
+
end
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
# The turn's state before anything can fail it: the session running,
|
|
1457
|
+
# the turn flag, its cancel controller and sink. The cross-thread state
|
|
1458
|
+
# is set here and cleared in #release_turn only.
|
|
1459
|
+
def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
|
|
1513
1460
|
# Track the active session for recap and status snapshot.
|
|
1514
1461
|
@session = session
|
|
1515
1462
|
# status is turn state: running now, idle again before the turn's end
|
|
1516
1463
|
# is announced, so a UI reacting to that event reads the new state.
|
|
1517
1464
|
session.status = Session::STATUS_RUNNING
|
|
1518
|
-
|
|
1519
|
-
#
|
|
1520
|
-
|
|
1521
|
-
#
|
|
1522
|
-
|
|
1465
|
+
@used_memories.absorb(session)
|
|
1466
|
+
# Provide a cancellable controller for this turn (cross-process cancel via file flag)
|
|
1467
|
+
effective_controller = cancel_controller || CancellationController.new
|
|
1468
|
+
# The turn runs, with its controller and sink, in one step: a Stop
|
|
1469
|
+
# or a plugin's card from another thread never sees a running turn
|
|
1470
|
+
# without them. The idle recap detector does not fire (or render an
|
|
1471
|
+
# invalidated recap) while the model is working. A hook's notice goes
|
|
1472
|
+
# where the turn's events go (the REPL renders only its sink; a
|
|
1473
|
+
# worker's observers carry it to the bridge).
|
|
1474
|
+
@turn_state.begin!(controller: effective_controller, sink: on_event)
|
|
1475
|
+
# Drop a recap already in flight: the turn makes it stale.
|
|
1523
1476
|
@recap&.invalidate!
|
|
1477
|
+
# The gate's context: who queued this turn, and git asked afresh.
|
|
1478
|
+
@guardrail_wiring.begin_turn(origin)
|
|
1479
|
+
|
|
1480
|
+
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
1481
|
+
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
1482
|
+
end
|
|
1483
|
+
private :begin_turn
|
|
1484
|
+
|
|
1485
|
+
# Everything before the model is asked: :turn_started, the kernel's
|
|
1486
|
+
# per-turn settings, the plugins' setup, the session_start/before_turn
|
|
1487
|
+
# hooks, then the messages to send (#turn_messages). False when a Stop
|
|
1488
|
+
# came before the hooks (the turn ends canceled there; before the
|
|
1489
|
+
# load-failure warnings when it came during the probes), else true.
|
|
1490
|
+
def prepare_turn(turn, images)
|
|
1491
|
+
session = turn.session
|
|
1492
|
+
image_refs, image_error = turn_image_refs(session, images)
|
|
1493
|
+
# Emit turn_started event
|
|
1494
|
+
bind_metrics(session)
|
|
1495
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
|
|
1496
|
+
turn_started[:continue] = true if turn.continue
|
|
1497
|
+
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1498
|
+
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1499
|
+
raise image_error if image_error
|
|
1500
|
+
|
|
1524
1501
|
# Ask the server for its window again each turn (one short /props GET,
|
|
1525
1502
|
# cached across the turn's generations): a restart with another -c
|
|
1526
|
-
# between turns raises no error that would drop the cache.
|
|
1527
|
-
|
|
1503
|
+
# between turns raises no error that would drop the cache. The
|
|
1504
|
+
# profile may probe too (a first turn, a retry). Both run after
|
|
1505
|
+
# run_turn's probe-cancel swap, so a Stop cuts them, and a failure
|
|
1506
|
+
# ends the turn as turn_failed.
|
|
1507
|
+
@client.invalidate_context_window!
|
|
1528
1508
|
refresh_profile!
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1509
|
+
|
|
1510
|
+
# Before anything of the turn is kept or a reminder is used up.
|
|
1511
|
+
turn.settings = turn_settings(session)
|
|
1512
|
+
refuse_images!(turn.settings.vision) unless image_refs.empty?
|
|
1513
|
+
# A Stop during the probes: the turn ends before the load-failure
|
|
1514
|
+
# warnings, which show once, so the next turn still shows them.
|
|
1515
|
+
return false if turn.controller.cancelled?
|
|
1516
|
+
|
|
1517
|
+
announce_guardrail_failures(turn.on_event)
|
|
1518
|
+
# Plugins' slow setup that brings tools (an MCP server's first
|
|
1519
|
+
# start): the turn waits for it here, before the system prompt
|
|
1520
|
+
# declares the tools; a Ctrl-C ends the wait (the turn is cancelled
|
|
1521
|
+
# at its first request).
|
|
1522
|
+
start_init_tasks!
|
|
1523
|
+
await_init_tasks(turn.controller, turn.on_event)
|
|
1524
|
+
# Plugins' tool sets that changed since the last turn.
|
|
1525
|
+
apply_staged_tools!
|
|
1526
|
+
# A Stop during the wait above: the turn ends here,
|
|
1527
|
+
# before the hooks run or a reminder is used up.
|
|
1528
|
+
return false if turn.controller.cancelled?
|
|
1529
|
+
|
|
1530
|
+
# Fire :session_start on the very first turn
|
|
1531
|
+
if @first_turn
|
|
1532
|
+
@hooks.fire(:session_start, { type: :session_start, session_id: session.id })
|
|
1533
|
+
@first_turn = false
|
|
1536
1534
|
end
|
|
1537
|
-
# The gate's context: who queued this turn, and git asked afresh.
|
|
1538
|
-
@guardrail_wiring.begin_turn(origin)
|
|
1539
1535
|
|
|
1540
|
-
|
|
1541
|
-
#
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
messages = nil
|
|
1545
|
-
# Boundary events carry the origin only when there is one, so payloads
|
|
1546
|
-
# stay unchanged for callers that don't pass it.
|
|
1547
|
-
with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
|
|
1548
|
-
begin
|
|
1549
|
-
# A Stop cuts the /props probes this thread makes for the turn
|
|
1550
|
-
# (window, served model, vision) instead of waiting their timeout.
|
|
1551
|
-
probe_cancel_before = Client.swap_probe_cancel(effective_controller)
|
|
1552
|
-
image_refs, image_error = turn_image_refs(session, continue ? [] : images)
|
|
1553
|
-
# Emit turn_started event
|
|
1554
|
-
bind_metrics(session)
|
|
1555
|
-
turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
|
|
1556
|
-
turn_started[:continue] = true if continue
|
|
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
|
|
1536
|
+
# Fire :before_turn hook, with a read-only copy of the history so far
|
|
1537
|
+
# and the prompt (nil on a continue).
|
|
1538
|
+
@hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
|
|
1539
|
+
messages: hook_messages(session.messages) })
|
|
1585
1540
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
messages = session.messages.dup
|
|
1592
|
-
# Built once per Engine (and again after a model switch) so the prompt
|
|
1593
|
-
# prefix, and the model server's KV cache for it, stay stable.
|
|
1594
|
-
messages = ContextNote.with_system_head(messages, { role: "system", content: system_prompt })
|
|
1595
|
-
# Explicit --memory preloads are now known after system prompt build.
|
|
1596
|
-
add_used_memory_names(activated_memory_names)
|
|
1597
|
-
sync_used_memories_from_session(session)
|
|
1598
|
-
|
|
1599
|
-
# Inject due reminders as a tail system message (after history, before
|
|
1600
|
-
# the new user prompt) to preserve prefix KV cache. Mutating the head
|
|
1601
|
-
# system prompt invalidates cache for the entire prefix.
|
|
1602
|
-
due_reminders = collect_due_reminders(messages)
|
|
1603
|
-
if due_reminders.any?
|
|
1604
|
-
emit_event(on_event, {
|
|
1605
|
-
type: :reminder_injected,
|
|
1606
|
-
reminders: due_reminders
|
|
1607
|
-
})
|
|
1608
|
-
end
|
|
1541
|
+
turn_messages(turn, image_refs)
|
|
1542
|
+
true
|
|
1543
|
+
end
|
|
1544
|
+
private :prepare_turn
|
|
1609
1545
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1546
|
+
# A turn stopped before the model was asked (#prepare_turn returned
|
|
1547
|
+
# false): canceled with nothing kept, like a Ctrl-C there; the result
|
|
1548
|
+
# has no conversation, so a UI's checkpoint stands.
|
|
1549
|
+
def stopped_before_model(turn)
|
|
1550
|
+
reason = turn.controller.reason
|
|
1551
|
+
end_turn(turn, "canceled") do |seconds|
|
|
1552
|
+
[nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
|
|
1553
|
+
end
|
|
1554
|
+
LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
|
|
1555
|
+
end
|
|
1556
|
+
private :stopped_before_model
|
|
1557
|
+
|
|
1558
|
+
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
1559
|
+
# sampling and thinking, the thinking resolved before the hooks run.
|
|
1560
|
+
# The model name is added in #generate, after the hooks that may switch
|
|
1561
|
+
# the model.
|
|
1562
|
+
def turn_settings(session)
|
|
1563
|
+
vision = turn_vision(session)
|
|
1564
|
+
sampling = turn_sampling
|
|
1565
|
+
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1566
|
+
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1567
|
+
announce_thinking_level(*@turn_thinking)
|
|
1568
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
|
|
1569
|
+
end
|
|
1570
|
+
private :turn_settings
|
|
1571
|
+
|
|
1572
|
+
# The messages the turn sends: the history under the system head, due
|
|
1573
|
+
# reminders, the prompt. turn.messages is set first and grown in place,
|
|
1574
|
+
# so a Ctrl-C or a failure meanwhile keeps what is there.
|
|
1575
|
+
def turn_messages(turn, image_refs)
|
|
1576
|
+
session = turn.session
|
|
1577
|
+
turn.messages = session.messages.dup
|
|
1578
|
+
# Built once per Engine (and again after a model switch) so the prompt
|
|
1579
|
+
# prefix, and the model server's KV cache for it, stay stable.
|
|
1580
|
+
turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
|
|
1581
|
+
# Explicit --memory preloads are now known after system prompt build.
|
|
1582
|
+
@used_memories.add(activated_memory_names).absorb(session)
|
|
1583
|
+
|
|
1584
|
+
# Inject due reminders as a tail system message (after history, before
|
|
1585
|
+
# the new user prompt) to preserve prefix KV cache. Mutating the head
|
|
1586
|
+
# system prompt invalidates cache for the entire prefix.
|
|
1587
|
+
due_reminders = @reminder_queue.inject!(turn.messages)
|
|
1588
|
+
if due_reminders.any?
|
|
1589
|
+
emit_event(turn.on_event, {
|
|
1590
|
+
type: :reminder_injected,
|
|
1591
|
+
reminders: due_reminders
|
|
1592
|
+
})
|
|
1593
|
+
end
|
|
1616
1594
|
|
|
1617
|
-
|
|
1618
|
-
# Route model name as bare (without host prefix) to the transport;
|
|
1619
|
-
# host selection already done via active client.
|
|
1620
|
-
bare_for_backend = bare_model_name(@effective_model_name)
|
|
1621
|
-
# The chat loop dispatches tools through the kernel without its #run:
|
|
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
|
|
1595
|
+
return if turn.continue
|
|
1651
1596
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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)
|
|
1597
|
+
user_message = { role: "user", content: turn.prompt }
|
|
1598
|
+
user_message[:images] = image_refs unless image_refs.empty?
|
|
1599
|
+
turn.messages << user_message
|
|
1600
|
+
session.last_prompt = turn.prompt
|
|
1601
|
+
end
|
|
1602
|
+
private :turn_messages
|
|
1709
1603
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1604
|
+
# Ask the effective model's backend.
|
|
1605
|
+
def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
1606
|
+
sync_kernel_client!
|
|
1607
|
+
# Route model name as bare (without host prefix) to the transport;
|
|
1608
|
+
# host selection already done via active client.
|
|
1609
|
+
bare_for_backend = bare_model_name(@effective_model_name)
|
|
1610
|
+
# The turn's settings, set once here. The chat loop dispatches tools
|
|
1611
|
+
# through the kernel without its #run: tag those dumps with this
|
|
1612
|
+
# turn's model, not the last native one.
|
|
1613
|
+
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
|
|
1614
|
+
|
|
1615
|
+
backend.complete(
|
|
1616
|
+
messages: turn.messages,
|
|
1617
|
+
max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
|
|
1618
|
+
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
1619
|
+
cancel_controller: turn.controller,
|
|
1620
|
+
model_name: bare_for_backend,
|
|
1621
|
+
max_tool_output_chars: max_tool_output_chars,
|
|
1622
|
+
pending_input: turn_drain(pending_input)
|
|
1623
|
+
)
|
|
1624
|
+
end
|
|
1625
|
+
private :generate
|
|
1719
1626
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1627
|
+
def publish_used_memories(session, on_event)
|
|
1628
|
+
# Persist deduped used memories onto the session for Web + reload.
|
|
1629
|
+
begin
|
|
1630
|
+
session.used_memory_names = used_memory_names
|
|
1631
|
+
rescue StandardError
|
|
1632
|
+
nil
|
|
1633
|
+
end
|
|
1634
|
+
# Notify live observers of the updated memory list (so yellow bar refreshes
|
|
1635
|
+
# even without a tool_call event if the preload was the only addition).
|
|
1636
|
+
# Only emit when there is something to report to avoid noisy event_count drift.
|
|
1637
|
+
return unless used_memory_names.any?
|
|
1722
1638
|
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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 = [] }
|
|
1639
|
+
begin
|
|
1640
|
+
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
|
|
1641
|
+
rescue StandardError
|
|
1642
|
+
nil
|
|
1643
|
+
end
|
|
1644
|
+
end
|
|
1645
|
+
private :publish_used_memories
|
|
1646
|
+
|
|
1647
|
+
# The normal ending (an answer, an empty answer, a Stop, the iteration
|
|
1648
|
+
# limit), then the after-turn hooks.
|
|
1649
|
+
def complete_turn(turn, result)
|
|
1650
|
+
canceled = result.canceled?
|
|
1651
|
+
# after_turn hooks run below and may present the answer (the web
|
|
1652
|
+
# holds its pop until it knows).
|
|
1653
|
+
display_pending = !canceled && @hooks.any?(:after_turn)
|
|
1654
|
+
end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
|
|
1655
|
+
kept = kept_messages(turn, result, seconds)
|
|
1656
|
+
if canceled
|
|
1657
|
+
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
1658
|
+
duration_ms: (seconds * 1000).round }]
|
|
1659
|
+
else
|
|
1660
|
+
# For a client that attaches later (session_state_snapshot).
|
|
1661
|
+
@last_context_status = result.context_status.dup if result.context_status
|
|
1662
|
+
[kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
|
|
1663
|
+
display_pending: display_pending }]
|
|
1777
1664
|
end
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1667
|
+
# Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
|
|
1668
|
+
# ends the turn in #run_turn without it), with a read-only
|
|
1669
|
+
# copy of the conversation the turn stored, and event[:present] for
|
|
1670
|
+
# a display version of the answer (AnswerDisplay).
|
|
1671
|
+
session = turn.session
|
|
1672
|
+
display = AnswerDisplay.new(session.messages)
|
|
1673
|
+
after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
|
|
1674
|
+
messages: hook_messages(session.messages) }
|
|
1675
|
+
after_turn[:present] = display.presenter(after_turn)
|
|
1676
|
+
@hooks.fire(:after_turn, after_turn)
|
|
1677
|
+
store_answer_display(session, display, pending: display_pending)
|
|
1678
|
+
|
|
1679
|
+
# Fire :session_end after the turn (turn-level lifecycle), as :after_turn
|
|
1680
|
+
@hooks.fire(:session_end, { type: :session_end, session_id: session.id })
|
|
1681
|
+
end
|
|
1682
|
+
private :complete_turn
|
|
1683
|
+
|
|
1684
|
+
# What the session keeps of a turn that ended normally (nil: nothing to
|
|
1685
|
+
# replace). Runs under the event lock: it also adds the turn's note to
|
|
1686
|
+
# result.conversation, which the REPL keeps as-is.
|
|
1687
|
+
# A turn that ran out of iterations ends at its tool results, so a
|
|
1688
|
+
# continue resumes from them rather than after a made-up reply.
|
|
1689
|
+
def kept_messages(turn, result, seconds)
|
|
1690
|
+
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
1691
|
+
# Nothing visible (the native loop: no text; the chat loop: its
|
|
1692
|
+
# placeholder text) is a turn the model should know ended that way.
|
|
1693
|
+
if result.empty_answer?
|
|
1694
|
+
# The placeholder is for the UIs (a new array: it must not leak
|
|
1695
|
+
# into the result); the note is for the model, so it goes on the
|
|
1696
|
+
# result's conversation too.
|
|
1697
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1698
|
+
note = TurnNote.empty
|
|
1699
|
+
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1700
|
+
saved = TurnNote.without_trailing(conversation || turn.session.messages)
|
|
1701
|
+
saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
|
|
1702
|
+
conversation << note if conversation
|
|
1703
|
+
saved + [note]
|
|
1704
|
+
elsif result.canceled? && conversation
|
|
1705
|
+
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
|
|
1706
|
+
stopped_by: turn.controller.detail,
|
|
1707
|
+
shown: TurnNote.interrupted_tail?(conversation),
|
|
1708
|
+
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
1709
|
+
else
|
|
1710
|
+
conversation
|
|
1782
1711
|
end
|
|
1783
1712
|
end
|
|
1713
|
+
private :kept_messages
|
|
1714
|
+
|
|
1715
|
+
# A Ctrl-C keeps the turn's messages so far with a cancel note (nil
|
|
1716
|
+
# before there are any). Only the pre-turn messages survive here, so
|
|
1717
|
+
# tasks this turn started are missed (plan wait-stop §6).
|
|
1718
|
+
def ctrl_c_messages(turn, seconds)
|
|
1719
|
+
return nil unless turn.messages
|
|
1720
|
+
|
|
1721
|
+
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(turn.messages))
|
|
1722
|
+
TurnNote.replace_trailing(turn.messages, note)
|
|
1723
|
+
end
|
|
1724
|
+
private :ctrl_c_messages
|
|
1725
|
+
|
|
1726
|
+
# A failure keeps the loop's partial conversation (else the turn's
|
|
1727
|
+
# messages so far) with a failed note; nil when the turn never reached
|
|
1728
|
+
# the model. The model reads why on its next turn (a UI that rolls the
|
|
1729
|
+
# turn back leaves its own note, TurnFlow#prompt_turn_failed).
|
|
1730
|
+
def failed_messages(turn, error)
|
|
1731
|
+
kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
|
|
1732
|
+
return nil unless kept
|
|
1733
|
+
|
|
1734
|
+
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
1735
|
+
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
|
|
1736
|
+
end
|
|
1737
|
+
private :failed_messages
|
|
1738
|
+
|
|
1739
|
+
def failed_event(error, seconds)
|
|
1740
|
+
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
1741
|
+
duration_ms: (seconds * 1000).round }
|
|
1742
|
+
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
1743
|
+
if error.is_a?(LLM::ProviderError)
|
|
1744
|
+
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
|
|
1745
|
+
end
|
|
1746
|
+
failed
|
|
1747
|
+
end
|
|
1748
|
+
private :failed_event
|
|
1749
|
+
|
|
1750
|
+
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
1751
|
+
# probe cancel restored, the cancel controller, sink and steers cleared
|
|
1752
|
+
# (a steer left is logged as dropped), activity recorded, the
|
|
1753
|
+
# turn-scoped hooks cleared.
|
|
1754
|
+
def release_turn(probe_cancel_before)
|
|
1755
|
+
# A completed turn is activity: release the turn flag and advance the
|
|
1756
|
+
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1757
|
+
# treats the just-finished turn as activity and re-arms its window.
|
|
1758
|
+
left = @turn_state.finish!
|
|
1759
|
+
Client.swap_probe_cancel(probe_cancel_before)
|
|
1760
|
+
log_dropped_steers(left, "turn_ended")
|
|
1761
|
+
record_activity
|
|
1762
|
+
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
1763
|
+
clear_hooks
|
|
1764
|
+
end
|
|
1765
|
+
private :release_turn
|
|
1784
1766
|
|
|
1785
1767
|
# [refs, nil] for a turn's images, or [[], error] when one can't be used
|
|
1786
1768
|
# (the turn then fails right after :turn_started).
|
|
@@ -1839,7 +1821,7 @@ module Samagotchi
|
|
|
1839
1821
|
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
1840
1822
|
|
|
1841
1823
|
client = target.client
|
|
1842
|
-
props = client.
|
|
1824
|
+
props = client.cached_server_props(model: target.bare_model)
|
|
1843
1825
|
return unless Thinking.effort_ignored?(level, props)
|
|
1844
1826
|
|
|
1845
1827
|
thinking_notice_once(:unsupported, target, :info,
|
|
@@ -1964,8 +1946,16 @@ module Samagotchi
|
|
|
1964
1946
|
|
|
1965
1947
|
private
|
|
1966
1948
|
|
|
1949
|
+
# The one named seam for replacing a session's conversation (the
|
|
1950
|
+
# array is replaced, never mutated in place). No lock: a reader gets
|
|
1951
|
+
# the old array or the new one, and both are whole.
|
|
1952
|
+
# The turn as other threads see it (TurnState).
|
|
1953
|
+
attr_reader :turn_state
|
|
1954
|
+
# The due reminders on their way into a turn (ReminderQueue).
|
|
1955
|
+
attr_reader :reminder_queue
|
|
1956
|
+
|
|
1967
1957
|
def replace_session_messages(session, messages)
|
|
1968
|
-
|
|
1958
|
+
session.messages = messages
|
|
1969
1959
|
end
|
|
1970
1960
|
|
|
1971
1961
|
# Pick the loop for a target; the chat loop is pointed at the target
|
|
@@ -2008,7 +1998,8 @@ module Samagotchi
|
|
|
2008
1998
|
end
|
|
2009
1999
|
begin
|
|
2010
2000
|
Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
|
|
2011
|
-
failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {}
|
|
2001
|
+
failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
|
|
2002
|
+
requires_chi: data[:requires_chi])
|
|
2012
2003
|
rescue Exception => e
|
|
2013
2004
|
Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
|
|
2014
2005
|
end
|
|
@@ -2081,11 +2072,9 @@ module Samagotchi
|
|
|
2081
2072
|
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
|
|
2082
2073
|
hook_ask_user(question, options, header, allow_freeform, hook)
|
|
2083
2074
|
},
|
|
2084
|
-
# Nothing to cancel while the Engine is still being built (a
|
|
2085
|
-
# server that starts as its plugin loads).
|
|
2086
2075
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2087
2076
|
cancelled: lambda {
|
|
2088
|
-
(task = current_init_task) ? task.cancelled? :
|
|
2077
|
+
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|
|
2089
2078
|
},
|
|
2090
2079
|
card: ->(**card) { show_card(**card) },
|
|
2091
2080
|
steer: ->(text, label) { steer(text, source: hook_source(label)) },
|
|
@@ -2159,7 +2148,7 @@ module Samagotchi
|
|
|
2159
2148
|
@auto_turn_callback = auto_turn_callback
|
|
2160
2149
|
IdleReminders.new(
|
|
2161
2150
|
engine: self,
|
|
2162
|
-
|
|
2151
|
+
queue: @reminder_queue,
|
|
2163
2152
|
callback: @auto_turn_callback
|
|
2164
2153
|
)
|
|
2165
2154
|
end
|
|
@@ -2221,7 +2210,7 @@ module Samagotchi
|
|
|
2221
2210
|
def emit_event(on_event, event)
|
|
2222
2211
|
# Capture used memories synchronously in the turn thread.
|
|
2223
2212
|
read_names = begin
|
|
2224
|
-
|
|
2213
|
+
@used_memories.capture(event, muted: @muted_memory_names)
|
|
2225
2214
|
rescue StandardError
|
|
2226
2215
|
nil
|
|
2227
2216
|
end
|
|
@@ -2253,7 +2242,7 @@ module Samagotchi
|
|
|
2253
2242
|
adapter = nil
|
|
2254
2243
|
if target.entry.chat?
|
|
2255
2244
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2256
|
-
client = nil if adapter.
|
|
2245
|
+
client = nil if adapter.remote?
|
|
2257
2246
|
end
|
|
2258
2247
|
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
|
|
2259
2248
|
rescue StandardError
|
|
@@ -2271,9 +2260,9 @@ module Samagotchi
|
|
|
2271
2260
|
client = target.client
|
|
2272
2261
|
if target.entry.chat?
|
|
2273
2262
|
adapter = @host_registry.adapter_for(target.entry)
|
|
2274
|
-
return [nil, nil] if adapter.
|
|
2263
|
+
return [nil, nil] if adapter.remote?
|
|
2275
2264
|
end
|
|
2276
|
-
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2265
|
+
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2277
2266
|
served ? [served, asked] : [nil, nil]
|
|
2278
2267
|
rescue StandardError
|
|
2279
2268
|
[nil, nil]
|
|
@@ -2300,7 +2289,7 @@ module Samagotchi
|
|
|
2300
2289
|
# prompt format and parser, and the system prompts built for the old one.
|
|
2301
2290
|
def apply_profile(resolution)
|
|
2302
2291
|
@prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
|
|
2303
|
-
@kernel.use_profile!(resolution)
|
|
2292
|
+
@kernel.use_profile!(resolution)
|
|
2304
2293
|
resolution
|
|
2305
2294
|
end
|
|
2306
2295
|
|