samagotchi 0.110.0 → 0.111.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "context_note"
|
|
|
13
13
|
require_relative "context_status"
|
|
14
14
|
require_relative "steer"
|
|
15
15
|
require_relative "turn_note"
|
|
16
|
+
require_relative "turn_endings"
|
|
16
17
|
require_relative "turn_origin"
|
|
17
18
|
require_relative "model_profile"
|
|
18
19
|
require_relative "effective_model"
|
|
@@ -53,6 +54,7 @@ require_relative "recap_setup"
|
|
|
53
54
|
require_relative "idle_reminders"
|
|
54
55
|
require_relative "idle_scheduler"
|
|
55
56
|
require_relative "hooks"
|
|
57
|
+
require_relative "event_fanout"
|
|
56
58
|
require_relative "event_routes"
|
|
57
59
|
require_relative "hook_routes"
|
|
58
60
|
require_relative "guardrails"
|
|
@@ -65,7 +67,9 @@ require_relative "used_memories"
|
|
|
65
67
|
require_relative "turn_state"
|
|
66
68
|
require_relative "steer_cut"
|
|
67
69
|
require_relative "engine_peers"
|
|
70
|
+
require_relative "engine_snapshots"
|
|
68
71
|
require_relative "bundle_needs"
|
|
72
|
+
require_relative "model_binding"
|
|
69
73
|
require_relative "model_overlay"
|
|
70
74
|
require_relative "model_notes"
|
|
71
75
|
require_relative "prompt_note"
|
|
@@ -142,6 +146,12 @@ module Samagotchi
|
|
|
142
146
|
# A message for the running turn cutting its generation.
|
|
143
147
|
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
|
|
144
148
|
cut_after: -> { steer_cut_after })
|
|
149
|
+
# How an event reaches the turn's sink and the observers, and the
|
|
150
|
+
# stream handler. Built early: the question desk's and the init tasks'
|
|
151
|
+
# emit go through it.
|
|
152
|
+
@event_fanout = build_event_fanout
|
|
153
|
+
# What each turn ending keeps and announces (one lookup).
|
|
154
|
+
@turn_endings = build_turn_endings
|
|
145
155
|
@scratch = scratch
|
|
146
156
|
@no_interrupt = no_interrupt
|
|
147
157
|
@chat_backend = nil
|
|
@@ -152,22 +162,22 @@ module Samagotchi
|
|
|
152
162
|
# is used (the REPL checks the model it starts on, #switch_model!).
|
|
153
163
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
154
164
|
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
155
|
-
#
|
|
156
|
-
|
|
165
|
+
# The model choice and the profile resolved for it, under one mutex.
|
|
166
|
+
# A caller's profile pins it (until a model switch); otherwise
|
|
167
|
+
# #profile_resolution decides on first need (see there), so building an
|
|
168
|
+
# Engine makes no network call.
|
|
157
169
|
typed = model_typed.to_s.strip
|
|
158
|
-
@
|
|
170
|
+
@model_binding = build_model_binding(
|
|
171
|
+
ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed),
|
|
172
|
+
profile ? ModelProfile.normalize(profile) : nil
|
|
173
|
+
)
|
|
159
174
|
# The session's settings on its effective model (SessionModelSettings),
|
|
160
175
|
# read at use: /model moves the choice, #session= the session.
|
|
161
|
-
@model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @
|
|
176
|
+
@model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_binding.choice },
|
|
162
177
|
session: -> { @session })
|
|
163
178
|
# An injected client (specs) stands in for every host's client.
|
|
164
179
|
@host_registry.client_override = client if client
|
|
165
180
|
@client = effective_model.client
|
|
166
|
-
# A caller's profile pins it (until a model switch); otherwise
|
|
167
|
-
# #profile_resolution decides on first need (see there), so building an
|
|
168
|
-
# Engine makes no network call.
|
|
169
|
-
@given_profile = profile ? ModelProfile.normalize(profile) : nil
|
|
170
|
-
@profile_resolution = nil
|
|
171
181
|
# Ensure the built-in system bundle is installed (lazy, warn-only).
|
|
172
182
|
# This is the single seam for both TUI and non-TUI (web/worker) paths.
|
|
173
183
|
begin
|
|
@@ -254,6 +264,9 @@ module Samagotchi
|
|
|
254
264
|
# the single background thread and isolates per-job failures.
|
|
255
265
|
@idle_scheduler = build_idle_scheduler
|
|
256
266
|
subscribe_session_observers
|
|
267
|
+
# The UIs' read model (/state, /stats). Built last: nothing calls a
|
|
268
|
+
# snapshot while the Engine is built.
|
|
269
|
+
@engine_snapshots = build_engine_snapshots
|
|
257
270
|
end
|
|
258
271
|
|
|
259
272
|
# ── initialize's collaborators ──────────────────────────────────────────
|
|
@@ -338,7 +351,7 @@ module Samagotchi
|
|
|
338
351
|
def wire_kernel(kernel, muted_memories)
|
|
339
352
|
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
340
353
|
# the one built here does.
|
|
341
|
-
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: effective_model_name,
|
|
354
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @model_binding.given_profile, model_name: effective_model_name,
|
|
342
355
|
hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
|
|
343
356
|
@kernel.reminder_store = @reminder_store
|
|
344
357
|
@kernel.hooks = @hooks
|
|
@@ -383,7 +396,7 @@ module Samagotchi
|
|
|
383
396
|
hooks: -> { @hooks },
|
|
384
397
|
tools: -> { @tools },
|
|
385
398
|
session: -> { @session },
|
|
386
|
-
model_key: -> { @model_key },
|
|
399
|
+
model_key: -> { @model_binding.model_key },
|
|
387
400
|
model_name: -> { guardrail_model_name },
|
|
388
401
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
389
402
|
# An approval of the model's call: :question_asked first (QuestionAsked).
|
|
@@ -393,6 +406,74 @@ module Samagotchi
|
|
|
393
406
|
end
|
|
394
407
|
private :build_guardrail_wiring
|
|
395
408
|
|
|
409
|
+
# The event fan-out and the stream handler (see #initialize for when).
|
|
410
|
+
def build_event_fanout
|
|
411
|
+
EventFanout.new(
|
|
412
|
+
observer: -> { @session_observer },
|
|
413
|
+
used_memories: -> { @used_memories },
|
|
414
|
+
muted: -> { @muted_memory_names },
|
|
415
|
+
steer_cut: -> { @steer_cut },
|
|
416
|
+
thinking_notices: -> { @thinking_notices },
|
|
417
|
+
metrics: -> { @metrics },
|
|
418
|
+
hooks: -> { @hooks },
|
|
419
|
+
kernel: -> { @kernel },
|
|
420
|
+
model_settings: -> { @model_settings },
|
|
421
|
+
session: -> { @session },
|
|
422
|
+
live_conversation: -> { live_conversation },
|
|
423
|
+
used_memory_names: -> { used_memory_names },
|
|
424
|
+
served_expected_by: ->(served) { served_expected_by(served) },
|
|
425
|
+
working_set_strategy: -> { working_set_strategy },
|
|
426
|
+
turn_running: -> { turn_running? },
|
|
427
|
+
announce_or_hold: ->(event) { announce_or_hold(event) }
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
private :build_event_fanout
|
|
431
|
+
|
|
432
|
+
# The snapshots the UIs read (see #initialize for when).
|
|
433
|
+
def build_engine_snapshots
|
|
434
|
+
EngineSnapshots.new(
|
|
435
|
+
session: -> { @session },
|
|
436
|
+
metrics: -> { @metrics },
|
|
437
|
+
observer: -> { @session_observer },
|
|
438
|
+
question_desk: -> { @question_desk },
|
|
439
|
+
used_memories: -> { @used_memories },
|
|
440
|
+
muted: -> { @muted_memory_names },
|
|
441
|
+
prompt_builder: -> { @prompt_builder },
|
|
442
|
+
participants: -> { @participants },
|
|
443
|
+
participants_watch: -> { @participants_watch },
|
|
444
|
+
recap: -> { @recap },
|
|
445
|
+
last_context_status: -> { @last_context_status },
|
|
446
|
+
model_settings: -> { @model_settings },
|
|
447
|
+
effective_model: -> { effective_model },
|
|
448
|
+
effective_model_name: -> { effective_model_name },
|
|
449
|
+
effective_model_ref: -> { effective_model_ref },
|
|
450
|
+
bare_model_name: ->(ref) { bare_model_name(ref) },
|
|
451
|
+
served_model: ->(probe:) { served_model(probe: probe) },
|
|
452
|
+
prompt_notes: -> { prompt_notes },
|
|
453
|
+
preloaded: -> { preloaded_memory_names },
|
|
454
|
+
saved_context_status: ->(context, **opts) { saved_context_status(context, **opts) },
|
|
455
|
+
context_window: ->(model) { current_context_window(model) },
|
|
456
|
+
profile_resolution: -> { profile_resolution },
|
|
457
|
+
toolkit_summary: -> { toolkit_summary },
|
|
458
|
+
toolkit_explained: -> { toolkit_explained }
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
private :build_engine_snapshots
|
|
462
|
+
|
|
463
|
+
# The model choice and its prompt profile (see #initialize for when).
|
|
464
|
+
def build_model_binding(choice, given_profile)
|
|
465
|
+
ModelBinding.new(choice: choice, given_profile: given_profile, registry: @host_registry,
|
|
466
|
+
kernel: -> { @kernel }, prompt_builder: -> { @prompt_builder },
|
|
467
|
+
bare_name: ->(name) { bare_model_name(name) })
|
|
468
|
+
end
|
|
469
|
+
private :build_model_binding
|
|
470
|
+
|
|
471
|
+
# The turn endings' builders (see #initialize for when).
|
|
472
|
+
def build_turn_endings
|
|
473
|
+
TurnEndings.new(failure_summary: ->(error) { failure_summary(error) })
|
|
474
|
+
end
|
|
475
|
+
private :build_turn_endings
|
|
476
|
+
|
|
396
477
|
# The question flow (see #initialize for when).
|
|
397
478
|
def build_question_desk
|
|
398
479
|
QuestionDesk.new(
|
|
@@ -643,10 +724,10 @@ module Samagotchi
|
|
|
643
724
|
attr_reader :metrics
|
|
644
725
|
|
|
645
726
|
# The model as given (maybe an alias; HostRegistry#resolve applies it).
|
|
646
|
-
def effective_model_name = @
|
|
727
|
+
def effective_model_name = @model_binding.choice.name
|
|
647
728
|
|
|
648
729
|
# The model as typed at start or at the last /model.
|
|
649
|
-
def typed_model_name = @
|
|
730
|
+
def typed_model_name = @model_binding.choice.typed
|
|
650
731
|
|
|
651
732
|
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
652
733
|
# the alias applied, "host:id" when it names a host).
|
|
@@ -662,15 +743,15 @@ module Samagotchi
|
|
|
662
743
|
model = effective_model
|
|
663
744
|
entry = model.entry
|
|
664
745
|
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
665
|
-
SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
|
|
666
|
-
served: model.prompt_served, fallback_key: @fallback_model_key)
|
|
746
|
+
SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_binding.model_key,
|
|
747
|
+
served: model.prompt_served, fallback_key: @model_binding.fallback_model_key)
|
|
667
748
|
end
|
|
668
749
|
|
|
669
750
|
# The model notes for the effective model (ModelNotes): matched on its
|
|
670
751
|
# bare id and key, the session's mutes left out.
|
|
671
752
|
# @return [Array<ModelNotes::Note>]
|
|
672
753
|
def model_notes
|
|
673
|
-
ModelNotes.for(name: guardrail_model_name, key: @model_key, fallback_key: @fallback_model_key,
|
|
754
|
+
ModelNotes.for(name: guardrail_model_name, key: @model_binding.model_key, fallback_key: @model_binding.fallback_model_key,
|
|
674
755
|
muted: @muted_memory_names)
|
|
675
756
|
end
|
|
676
757
|
|
|
@@ -733,28 +814,17 @@ module Samagotchi
|
|
|
733
814
|
# The effective model's key (ModelOverlay.key_for its bare name): memory
|
|
734
815
|
# overlays, guardrail rules' models:.
|
|
735
816
|
# @return [String, nil]
|
|
736
|
-
|
|
817
|
+
def model_key = @model_binding.model_key
|
|
737
818
|
|
|
738
819
|
# The prompt profile for the effective model (see #profile_resolution).
|
|
739
820
|
# @return [ModelProfile]
|
|
740
821
|
def profile = profile_resolution.profile
|
|
741
822
|
|
|
742
|
-
# Which profile the effective model gets and where that came from
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
# and kept, so the system prompt and the server's KV prefix stay stable;
|
|
746
|
-
# #switch_model! starts over, and a failed server probe is retried before
|
|
747
|
-
# the next turn (#refresh_profile!). The kernel follows each resolution.
|
|
748
|
-
# Kept with the model choice it was resolved for: one that finishes
|
|
749
|
-
# after a switch is neither kept nor applied (#store_profile).
|
|
823
|
+
# Which profile the effective model gets and where that came from,
|
|
824
|
+
# kept with the model choice it was resolved for
|
|
825
|
+
# (ModelBinding#profile_resolution).
|
|
750
826
|
# @return [ModelProfile::Resolution]
|
|
751
|
-
def profile_resolution
|
|
752
|
-
choice = @model_choice
|
|
753
|
-
cached = @profile_resolution
|
|
754
|
-
return cached.last if cached&.first.equal?(choice)
|
|
755
|
-
|
|
756
|
-
store_profile(choice, resolve_profile(choice))
|
|
757
|
-
end
|
|
827
|
+
def profile_resolution = @model_binding.profile_resolution
|
|
758
828
|
attr_reader :default_model_name, :host_registry, :client
|
|
759
829
|
|
|
760
830
|
# @return [Commands::Registry] the commands this session runs: the
|
|
@@ -769,17 +839,8 @@ module Samagotchi
|
|
|
769
839
|
@host_registry.bare_name(full_ref)
|
|
770
840
|
end
|
|
771
841
|
|
|
772
|
-
# The memory overlay
|
|
773
|
-
|
|
774
|
-
# (before aliases were applied there): that key is read when the
|
|
775
|
-
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
776
|
-
def sync_model_key!
|
|
777
|
-
choice = @model_choice
|
|
778
|
-
@model_key = ModelOverlay.key_for(bare_model_name(choice.name))
|
|
779
|
-
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
|
|
780
|
-
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
781
|
-
@kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
782
|
-
end
|
|
842
|
+
# The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
|
|
843
|
+
def sync_model_key! = @model_binding.sync_model_key!
|
|
783
844
|
private :sync_model_key!
|
|
784
845
|
|
|
785
846
|
# The effective model's bare name for the guardrails' models: rules;
|
|
@@ -1089,13 +1150,7 @@ module Samagotchi
|
|
|
1089
1150
|
# once; checked here: an unknown host, an alias naming another host.
|
|
1090
1151
|
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
1091
1152
|
choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
|
|
1092
|
-
@
|
|
1093
|
-
# One assignment: a reader on another thread sees the old pair or the new one.
|
|
1094
|
-
@model_choice = choice
|
|
1095
|
-
# A profile given to .new was for the starting model.
|
|
1096
|
-
@given_profile = nil
|
|
1097
|
-
@profile_resolution = nil
|
|
1098
|
-
end
|
|
1153
|
+
@model_binding.switch!(choice)
|
|
1099
1154
|
sync_model_key!
|
|
1100
1155
|
@prompt_builder.reset!
|
|
1101
1156
|
# The notes the new model's prompt loads at its next build, so /model,
|
|
@@ -1196,53 +1251,10 @@ module Samagotchi
|
|
|
1196
1251
|
# @return [ReminderStore] the reminder store for inspection
|
|
1197
1252
|
attr_reader :reminder_store
|
|
1198
1253
|
|
|
1199
|
-
# The metrics for /stats
|
|
1200
|
-
#
|
|
1201
|
-
# profile come from the effective model's server, so this may make one
|
|
1202
|
-
# short /props GET; the cheap #session_state_snapshot never does.
|
|
1203
|
-
# price_host: the effective model's host, whose hosts.<name>.models
|
|
1204
|
-
# prices the cost estimate names. kit_prompt: the next turn's kit
|
|
1205
|
-
# prompt text ({kit:, tokens:}), only when its toolkit has one.
|
|
1206
|
-
# settings: the session's own settings ({"recap.enabled" => false}),
|
|
1207
|
-
# only when it has some.
|
|
1254
|
+
# The metrics for /stats, which may make one short /props GET
|
|
1255
|
+
# (EngineSnapshots#stats_snapshot).
|
|
1208
1256
|
# @return [Hash] @metrics.snapshot, filled in
|
|
1209
|
-
def stats_snapshot
|
|
1210
|
-
snapshot = @metrics.snapshot
|
|
1211
|
-
# One resolve for the whole snapshot (its readers' rescues stay theirs);
|
|
1212
|
-
# a model that doesn't resolve raises here, as it always has.
|
|
1213
|
-
model = effective_model
|
|
1214
|
-
served, served_for = served_model_for(snapshot, model: model)
|
|
1215
|
-
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1216
|
-
served_expected_by: model.served_expected_by(served),
|
|
1217
|
-
**setting_chips(SessionSetting::STATS, model),
|
|
1218
|
-
prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
|
|
1219
|
-
price_host: model.host_name)
|
|
1220
|
-
kit_prompt = kit_prompt_stats
|
|
1221
|
-
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1222
|
-
# Each participant's spend under their name (participants.json's).
|
|
1223
|
-
if (spend = snapshot[:spend_by_author])
|
|
1224
|
-
named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
|
|
1225
|
-
snapshot = snapshot.merge(spend_by_author: named)
|
|
1226
|
-
end
|
|
1227
|
-
# The session's own settings (chi --set), as --set names them.
|
|
1228
|
-
settings = @session&.settings&.flat || {}
|
|
1229
|
-
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
1230
|
-
unless snapshot.dig(:context, :window_tokens)
|
|
1231
|
-
window = current_context_window(model)
|
|
1232
|
-
if window
|
|
1233
|
-
snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
|
|
1234
|
-
window_source: window.source.to_s))
|
|
1235
|
-
end
|
|
1236
|
-
end
|
|
1237
|
-
# The chat loop uses no prompt profile: drop one a native turn reported.
|
|
1238
|
-
return snapshot.except(:profile, :profile_source) if model.chat?
|
|
1239
|
-
|
|
1240
|
-
unless snapshot[:profile]
|
|
1241
|
-
resolution = profile_resolution
|
|
1242
|
-
snapshot = snapshot.merge(profile: resolution.profile.name, profile_source: resolution.label)
|
|
1243
|
-
end
|
|
1244
|
-
snapshot
|
|
1245
|
-
end
|
|
1257
|
+
def stats_snapshot = @engine_snapshots.stats_snapshot
|
|
1246
1258
|
|
|
1247
1259
|
# ── The session's model settings (SessionModelSettings) ──────────────────
|
|
1248
1260
|
|
|
@@ -1313,67 +1325,14 @@ module Samagotchi
|
|
|
1313
1325
|
# else [nil, nil].
|
|
1314
1326
|
# @return [Array(String, String), Array(nil, nil)]
|
|
1315
1327
|
def served_model(probe: true)
|
|
1316
|
-
served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
|
|
1328
|
+
@engine_snapshots.served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
|
|
1317
1329
|
end
|
|
1318
1330
|
|
|
1319
1331
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
1320
|
-
# live event sequence
|
|
1321
|
-
#
|
|
1322
|
-
#
|
|
1323
|
-
|
|
1324
|
-
# @return [Hash] with keys:
|
|
1325
|
-
# :status [String, nil] current session status
|
|
1326
|
-
# :message_count [Integer] number of messages in the session
|
|
1327
|
-
# :last_prompt [String, nil] the last user prompt (empty string if none)
|
|
1328
|
-
# :event_seq [Integer] @session_observer.event_count
|
|
1329
|
-
# :metrics [Hash] @metrics.snapshot (per-session analytics)
|
|
1330
|
-
# :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
|
|
1331
|
-
# :used_memory_names [Array<String>] deduped memory names active this session
|
|
1332
|
-
# :prompt_notes [Array<Hash>] the model notes the prompt carried
|
|
1333
|
-
# ({name:, scope:, chars:, digest:}; #prompt_notes)
|
|
1334
|
-
# :parent_id [String, nil] the session that delegated this one
|
|
1335
|
-
# :model_name [String] the model turns run on now (after /model)
|
|
1336
|
-
# :served_model, :served_model_for [String, nil] what a generation of
|
|
1337
|
-
# that model reported serving, and the name asked (#served_model
|
|
1338
|
-
# without the probe)
|
|
1339
|
-
# :served_expected_by [String, nil] the host whose models: entry names
|
|
1340
|
-
# that served model under served: (#served_expected_by)
|
|
1341
|
-
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1342
|
-
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1343
|
-
# :participants [Array<Hash>] who is in the review now ({id:, name:,
|
|
1344
|
-
# kind:}; ParticipantsWatch#active), [] for nobody
|
|
1345
|
-
def session_state_snapshot
|
|
1346
|
-
served_pair = served_model(probe: false)
|
|
1347
|
-
metrics = @metrics.snapshot
|
|
1348
|
-
# One resolve for the model's fields; nil when it doesn't resolve (the
|
|
1349
|
-
# Bridge's /state and reset frames rely on this never raising): those
|
|
1350
|
-
# fields are nil then, as their own rescues always gave.
|
|
1351
|
-
model = @model_settings.model_setting(nil) { |resolved| resolved }
|
|
1352
|
-
{
|
|
1353
|
-
status: @session&.status,
|
|
1354
|
-
message_count: (@session&.messages || []).size,
|
|
1355
|
-
last_prompt: @session&.last_prompt,
|
|
1356
|
-
event_seq: @session_observer&.event_count,
|
|
1357
|
-
metrics: metrics,
|
|
1358
|
-
pending_question: @question_desk.pending&.to_h,
|
|
1359
|
-
used_memory_names: @used_memories.names,
|
|
1360
|
-
preloaded_memory_names: preloaded_memory_names,
|
|
1361
|
-
muted_memory_names: @muted_memory_names.dup,
|
|
1362
|
-
prompt_notes: prompt_notes.map(&:to_h),
|
|
1363
|
-
parent_id: @session&.parent_id,
|
|
1364
|
-
title: @session&.title,
|
|
1365
|
-
model_name: effective_model_ref,
|
|
1366
|
-
served_model: served_pair[0],
|
|
1367
|
-
served_model_for: served_pair[1],
|
|
1368
|
-
served_expected_by: model&.served_expected_by(served_pair[0]),
|
|
1369
|
-
context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
|
|
1370
|
-
**setting_chips(SessionSetting::ALL, model),
|
|
1371
|
-
recap_enabled: !@recap.nil?,
|
|
1372
|
-
recap_min_user_turns: @recap&.min_user_turns,
|
|
1373
|
-
recap_inactivity_seconds: @recap&.inactivity&.to_i,
|
|
1374
|
-
participants: @participants_watch.active
|
|
1375
|
-
}
|
|
1376
|
-
end
|
|
1332
|
+
# live event sequence; cheap, never probes, never raises
|
|
1333
|
+
# (EngineSnapshots#session_state_snapshot has its keys).
|
|
1334
|
+
# @return [Hash]
|
|
1335
|
+
def session_state_snapshot = @engine_snapshots.session_state_snapshot
|
|
1377
1336
|
|
|
1378
1337
|
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
1379
1338
|
def used_memory_names
|
|
@@ -1551,20 +1510,8 @@ module Samagotchi
|
|
|
1551
1510
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1552
1511
|
end
|
|
1553
1512
|
|
|
1554
|
-
# Why a turn stopped
|
|
1555
|
-
|
|
1556
|
-
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
1557
|
-
# Only what is known.
|
|
1558
|
-
def stop_facts(event)
|
|
1559
|
-
case event&.dig(:type)
|
|
1560
|
-
# kept_steps: the turn's work stayed (a parent must not send the task again).
|
|
1561
|
-
when :turn_failed
|
|
1562
|
-
{ error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
|
|
1563
|
-
when :turn_canceled
|
|
1564
|
-
{ cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
|
|
1565
|
-
else {}
|
|
1566
|
-
end
|
|
1567
|
-
end
|
|
1513
|
+
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1514
|
+
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1568
1515
|
private :stop_facts
|
|
1569
1516
|
|
|
1570
1517
|
# How every turn ends (completed, canceled, failed): the session gets
|
|
@@ -2486,89 +2433,17 @@ module Samagotchi
|
|
|
2486
2433
|
end
|
|
2487
2434
|
private :complete_turn
|
|
2488
2435
|
|
|
2489
|
-
# What
|
|
2490
|
-
#
|
|
2491
|
-
|
|
2492
|
-
# A turn that ran out of iterations ends at its tool results, so a
|
|
2493
|
-
# continue resumes from them rather than after a made-up reply (and a
|
|
2494
|
-
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
2495
|
-
def kept_messages(turn, result, seconds)
|
|
2496
|
-
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
2497
|
-
# Nothing visible (no text) is a turn the model should know ended
|
|
2498
|
-
# that way: the note, and nothing made up as its answer. The note's
|
|
2499
|
-
# marker is for the UIs (the notice, and the empty steps after a
|
|
2500
|
-
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
2501
|
-
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
2502
|
-
# turn's note stays: it says why that turn ended.
|
|
2503
|
-
if result.empty_answer?
|
|
2504
|
-
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
2505
|
-
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
2506
|
-
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
2507
|
-
conversation << note if conversation
|
|
2508
|
-
saved + [note]
|
|
2509
|
-
elsif result.canceled? && conversation
|
|
2510
|
-
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
|
|
2511
|
-
stopped_by: turn.controller.detail,
|
|
2512
|
-
shown: TurnNote.interrupted_tail?(conversation),
|
|
2513
|
-
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
2514
|
-
else
|
|
2515
|
-
conversation
|
|
2516
|
-
end
|
|
2517
|
-
end
|
|
2436
|
+
# What each turn ending keeps and announces is TurnEndings'
|
|
2437
|
+
# (#build_turn_endings); these forward to it.
|
|
2438
|
+
def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
|
|
2518
2439
|
private :kept_messages
|
|
2519
|
-
|
|
2520
|
-
# A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
|
|
2521
|
-
# finished tool iterations, every call answered: ToolResponse.run_batch),
|
|
2522
|
-
# else the turn's messages so far (it came before the loop), with a
|
|
2523
|
-
# cancel note naming the tasks still running; nil before there are any.
|
|
2524
|
-
def ctrl_c_messages(turn, interrupt, seconds)
|
|
2525
|
-
kept = loop_conversation(interrupt) || turn.messages
|
|
2526
|
-
return nil unless kept
|
|
2527
|
-
|
|
2528
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
|
|
2529
|
-
TurnNote.replace_trailing(kept, note)
|
|
2530
|
-
end
|
|
2440
|
+
def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
|
|
2531
2441
|
private :ctrl_c_messages
|
|
2532
|
-
|
|
2533
|
-
# The conversation the loop had built when +error+ ended the turn
|
|
2534
|
-
# (LLM::FailedTurn), or nil.
|
|
2535
|
-
def loop_conversation(error)
|
|
2536
|
-
error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
|
|
2537
|
-
end
|
|
2442
|
+
def loop_conversation(error) = @turn_endings.loop_conversation(error)
|
|
2538
2443
|
private :loop_conversation
|
|
2539
|
-
|
|
2540
|
-
# A failure keeps the loop's partial conversation (else the turn's
|
|
2541
|
-
# messages so far) with a failed note; nil when the turn never reached
|
|
2542
|
-
# the model. The model reads why on its next turn. A turn that got to
|
|
2543
|
-
# tool results (LLM::FailedTurn.progress) says so in the note; on the
|
|
2544
|
-
# error (kept_steps) too unless it failed in a way that rolls back
|
|
2545
|
-
# (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
|
|
2546
|
-
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
2547
|
-
# note of their own; -p --non-interactive keeps what this keeps.
|
|
2548
|
-
def failed_messages(turn, error)
|
|
2549
|
-
kept = loop_conversation(error) || turn.messages
|
|
2550
|
-
return nil unless kept
|
|
2551
|
-
|
|
2552
|
-
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
2553
|
-
error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
|
|
2554
|
-
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
2555
|
-
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
|
|
2556
|
-
end
|
|
2444
|
+
def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
|
|
2557
2445
|
private :failed_messages
|
|
2558
|
-
|
|
2559
|
-
# Called after #failed_messages, which marks the error's kept steps.
|
|
2560
|
-
def failed_event(error, seconds)
|
|
2561
|
-
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
2562
|
-
duration_ms: (seconds * 1000).round }
|
|
2563
|
-
# Its work stays: the UIs say so, as after a cancel.
|
|
2564
|
-
steps = LLM::FailedTurn.kept_steps(error)
|
|
2565
|
-
failed[:kept_steps] = steps if steps
|
|
2566
|
-
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
2567
|
-
if error.is_a?(LLM::ProviderError)
|
|
2568
|
-
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
|
|
2569
|
-
end
|
|
2570
|
-
failed
|
|
2571
|
-
end
|
|
2446
|
+
def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
|
|
2572
2447
|
private :failed_event
|
|
2573
2448
|
|
|
2574
2449
|
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
@@ -2626,23 +2501,10 @@ module Samagotchi
|
|
|
2626
2501
|
raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
|
|
2627
2502
|
end
|
|
2628
2503
|
|
|
2629
|
-
# JSON-safe digest of a finished turn for renderers (
|
|
2630
|
-
# Bridge): the bits of the native loop's result a UI needs beyond `result:`.
|
|
2504
|
+
# JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
|
|
2631
2505
|
# @param result [LLM::ModelResult]
|
|
2632
2506
|
# @return [Hash]
|
|
2633
|
-
def turn_summary(result)
|
|
2634
|
-
summary = {
|
|
2635
|
-
output: result.output.to_s,
|
|
2636
|
-
exhausted: result.exhausted?,
|
|
2637
|
-
resumable: result.resumable?,
|
|
2638
|
-
pending_tool_calls: result.pending_tool_calls?,
|
|
2639
|
-
tool_activity: Array(result.tool_activity).map(&:dup),
|
|
2640
|
-
context_status: result.context_status&.dup
|
|
2641
|
-
}
|
|
2642
|
-
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
2643
|
-
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
2644
|
-
summary
|
|
2645
|
-
end
|
|
2507
|
+
def turn_summary(result) = @turn_endings.turn_summary(result)
|
|
2646
2508
|
|
|
2647
2509
|
# ── Session messages API ───────────────────────────────────────────────
|
|
2648
2510
|
#
|
|
@@ -2893,7 +2755,7 @@ module Samagotchi
|
|
|
2893
2755
|
model: model, source: source)
|
|
2894
2756
|
},
|
|
2895
2757
|
model_name: -> { effective_model_ref },
|
|
2896
|
-
model_key: -> { @model_key },
|
|
2758
|
+
model_key: -> { @model_binding.model_key },
|
|
2897
2759
|
state_dir: -> { session_state_dir },
|
|
2898
2760
|
scratch: -> { @scratch },
|
|
2899
2761
|
setup: -> { SessionSetup.of(@session) },
|
|
@@ -3010,253 +2872,47 @@ module Samagotchi
|
|
|
3010
2872
|
|
|
3011
2873
|
# ── Event helpers ──────────────────────────────────────────────────────────
|
|
3012
2874
|
|
|
3013
|
-
#
|
|
3014
|
-
#
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
# only notifying the observer.
|
|
3018
|
-
# Wrap a raw kernel stream event and fan it out to the turn sink + observers.
|
|
3019
|
-
#
|
|
3020
|
-
# Each :generation_chunk comes split already, by the loop that streamed it:
|
|
3021
|
-
# besides the raw `content` (left untouched — the TUI thinking spinner,
|
|
3022
|
-
# analytics, and Bridge replay rely on it) it carries
|
|
3023
|
-
# * :text — visible prose (thinking AND tool_call blocks removed)
|
|
3024
|
-
# * :thinking — thinking-only content
|
|
3025
|
-
# KernelLoop splits a native stream with a ThoughtStreamSplitter per
|
|
3026
|
-
# generation (every profile, Gemma's thought channel included); the chat
|
|
3027
|
-
# loop gets reasoning apart from the answer. The web routes on those
|
|
3028
|
-
# fields (chunk_router.js).
|
|
3029
|
-
#
|
|
3030
|
-
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
3031
|
-
# each chunk's thinking and text after the UIs had it.
|
|
3032
|
-
#
|
|
3033
|
-
# +model_ref+: the turn's model (#generation_model_ref), added to each
|
|
3034
|
-
# :generation_started as `model_ref:` (both loops send through here).
|
|
3035
|
-
def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
|
|
3036
|
-
watch = stream_watch(cancel_controller)
|
|
3037
|
-
proc do |event|
|
|
3038
|
-
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
3039
|
-
@steer_cut.observe(event, progress)
|
|
3040
|
-
case event[:type]
|
|
3041
|
-
when :generation_started
|
|
3042
|
-
event = event.merge(model_ref: model_ref) if model_ref
|
|
3043
|
-
@thinking_notices.generation_started
|
|
3044
|
-
watch&.started(event[:iteration])
|
|
3045
|
-
when :generation_retrying
|
|
3046
|
-
# A dropped stream's step asked again: it streams from the start.
|
|
3047
|
-
if event[:restarted]
|
|
3048
|
-
@thinking_notices.generation_started
|
|
3049
|
-
watch&.started(event[:iteration], restarted: true)
|
|
3050
|
-
end
|
|
3051
|
-
when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
|
|
3052
|
-
end
|
|
3053
|
-
# The chat loop asked again without the thinking fields or the
|
|
3054
|
-
# reasoning sent back: a notice, not an event of its own.
|
|
3055
|
-
next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
|
|
3056
|
-
next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
3057
|
-
next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
|
|
3058
|
-
|
|
3059
|
-
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
3060
|
-
emit_event(on_event, event)
|
|
3061
|
-
# What this request sends: both loops start a generation after
|
|
3062
|
-
# their apply_llm_context!.
|
|
3063
|
-
emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
|
|
3064
|
-
watch.feed(**progress) if watch && progress
|
|
3065
|
-
@thinking_notices.generation_completed(event) if event[:type] == :generation_completed
|
|
3066
|
-
end
|
|
3067
|
-
end
|
|
3068
|
-
|
|
3069
|
-
# The working set (WorkingSet) as the model has it, as a
|
|
3070
|
-
# :working_set_updated event ({items:, at:}, adds: while the user's
|
|
3071
|
-
# adds are pending): at each request (the
|
|
3072
|
-
# stream handler's :generation_started, from the running turn's
|
|
3073
|
-
# conversation) and at turn end (#end_turn, from the session's kept
|
|
3074
|
-
# messages). Only when it differs from the last one emitted for this
|
|
3075
|
-
# session, and never while it is empty — except one empty event,
|
|
3076
|
-
# remembered, when the last one for this session had items (a
|
|
3077
|
-
# rollback removed the tool calls), so a tool-free turn emits none
|
|
3078
|
-
# and the next empty request sends nothing more. The web overlays it
|
|
3079
|
-
# on the rows' set; other UIs ignore it.
|
|
3080
|
-
#
|
|
3081
|
-
# Between turns (#announce_working_set: no +on_event+) it is
|
|
3082
|
-
# announced, under the next turn's strategy.
|
|
3083
|
-
def emit_working_set(on_event, session, conversation)
|
|
3084
|
-
return unless session && conversation
|
|
3085
|
-
|
|
3086
|
-
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
|
|
3087
|
-
pins: session.working_set_pins)
|
|
3088
|
-
|
|
3089
|
-
shown = snapshot.to_h.except(:at)
|
|
3090
|
-
last = @working_set_emitted
|
|
3091
|
-
if snapshot.empty?
|
|
3092
|
-
return unless last && last[0] == session.id && last[1] != { items: [] }
|
|
3093
|
-
elsif last == [session.id, shown]
|
|
3094
|
-
return
|
|
3095
|
-
end
|
|
3096
|
-
|
|
3097
|
-
@working_set_emitted = [session.id, shown]
|
|
3098
|
-
event = { type: :working_set_updated, **shown, at: snapshot.at }
|
|
3099
|
-
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
3100
|
-
rescue StandardError => e
|
|
3101
|
-
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3102
|
-
end
|
|
2875
|
+
# The stream handler, the working set and the event fan-out are
|
|
2876
|
+
# EventFanout's (#build_event_fanout); these forward to it.
|
|
2877
|
+
def build_stream_event_handler(...) = @event_fanout.build_stream_event_handler(...)
|
|
2878
|
+
def emit_working_set(...) = @event_fanout.emit_working_set(...)
|
|
3103
2879
|
private :emit_working_set
|
|
3104
|
-
|
|
3105
|
-
# The strategy the set is built under: the running turn's
|
|
3106
|
-
# (TurnSettings#llm_context: what its requests send); between turns
|
|
3107
|
-
# the next turn's (as #warm_up_next_turn: the turn's settings keep the
|
|
3108
|
-
# last turn's, stale after a /llm-context or /model change), the last
|
|
3109
|
-
# turn's when it can't be resolved.
|
|
3110
|
-
def working_set_llm_context
|
|
3111
|
-
turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
|
|
3112
|
-
return turn if turn_running?
|
|
3113
|
-
|
|
3114
|
-
@model_settings.turn_llm_context || turn
|
|
3115
|
-
end
|
|
2880
|
+
def working_set_llm_context = @event_fanout.working_set_llm_context
|
|
3116
2881
|
private :working_set_llm_context
|
|
3117
|
-
|
|
3118
|
-
# A :generation_completed with the generation's decode speed (`speed:`
|
|
3119
|
-
# {decode_tps:, source:}, nil without one), the session's running
|
|
3120
|
-
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
3121
|
-
# (`model_segments:`, the snapshot's) and whether its served
|
|
3122
|
-
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
3123
|
-
# reads them from the stream. SessionMetrics closes the generation here,
|
|
3124
|
-
# so the event it gets next finds it closed.
|
|
3125
|
-
def with_generation_report(event)
|
|
3126
|
-
report = @metrics.finish_generation
|
|
3127
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
|
|
3128
|
-
served_expected_by: served_expected_by(event[:served_model]))
|
|
3129
|
-
rescue StandardError => e
|
|
3130
|
-
Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3131
|
-
event
|
|
3132
|
-
end
|
|
2882
|
+
def with_generation_report(event) = @event_fanout.with_generation_report(event)
|
|
3133
2883
|
private :with_generation_report
|
|
3134
|
-
|
|
3135
|
-
# The turn's StreamWatch, or nil when no hook listens.
|
|
3136
|
-
def stream_watch(cancel_controller)
|
|
3137
|
-
return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
|
|
3138
|
-
|
|
3139
|
-
Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
|
|
3140
|
-
end
|
|
2884
|
+
def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
|
|
3141
2885
|
private :stream_watch
|
|
3142
|
-
|
|
3143
|
-
def emit_event(on_event, event)
|
|
3144
|
-
# Capture used memories synchronously in the turn thread.
|
|
3145
|
-
read_names = begin
|
|
3146
|
-
@used_memories.capture(event, muted: @muted_memory_names)
|
|
3147
|
-
rescue StandardError => e
|
|
3148
|
-
Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3149
|
-
nil
|
|
3150
|
-
end
|
|
3151
|
-
# Turn-scoped sink: receives the event hash as the stream handler
|
|
3152
|
-
# passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
|
|
3153
|
-
# kernel loop (same as KernelLoop's own handling).
|
|
3154
|
-
if on_event
|
|
3155
|
-
begin
|
|
3156
|
-
on_event.call(event)
|
|
3157
|
-
rescue StandardError => e
|
|
3158
|
-
# Sink errors must not break the kernel loop (same as KernelLoop's own handling)
|
|
3159
|
-
Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3160
|
-
end
|
|
3161
|
-
end
|
|
3162
|
-
|
|
3163
|
-
# Persistent subscribers: receive a copy with a locally-monotonic
|
|
3164
|
-
# `event_seq`, fan out with per-subscriber error isolation.
|
|
3165
|
-
@session_observer.notify(event)
|
|
3166
|
-
# Every memory read, after its tool_call_started: the whole list for
|
|
3167
|
-
# the UIs' memory line, and the names this call read (read_names).
|
|
3168
|
-
return unless read_names
|
|
3169
|
-
|
|
3170
|
-
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
|
|
3171
|
-
end
|
|
2886
|
+
def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
|
|
3172
2887
|
|
|
3173
2888
|
# The effective model's window_tokens and price, read per turn
|
|
3174
2889
|
# (SessionModelSettings).
|
|
3175
2890
|
def turn_window_setting = @model_settings.turn_window_setting
|
|
3176
2891
|
def turn_price = @model_settings.turn_price
|
|
3177
2892
|
|
|
3178
|
-
#
|
|
3179
|
-
#
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
def served_model_for(snapshot, model: nil)
|
|
3183
|
-
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
3184
|
-
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
3185
|
-
return [nil, nil] unless model
|
|
3186
|
-
|
|
3187
|
-
model.served_from_props(asked)
|
|
3188
|
-
# Only bare_model_name and the snapshot lookup are left to fail here:
|
|
3189
|
-
# the probe's failure is EffectiveModel#served_from_props's.
|
|
3190
|
-
rescue StandardError
|
|
3191
|
-
[nil, nil]
|
|
3192
|
-
end
|
|
3193
|
-
|
|
3194
|
-
# The next turn's kit prompt text for /stats: its kit and its estimated
|
|
3195
|
-
# tokens (chars / 4), nil when its toolkit has none.
|
|
3196
|
-
def kit_prompt_stats
|
|
3197
|
-
resolved = toolkit_explained.resolved
|
|
3198
|
-
return nil unless resolved.prompt
|
|
3199
|
-
|
|
3200
|
-
{ kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
|
|
3201
|
-
end
|
|
2893
|
+
# The snapshots' helpers are EngineSnapshots' (#build_engine_snapshots);
|
|
2894
|
+
# these forward to it.
|
|
2895
|
+
def served_model_for(...) = @engine_snapshots.served_model_for(...)
|
|
2896
|
+
def kit_prompt_stats = @engine_snapshots.kit_prompt_stats
|
|
3202
2897
|
|
|
3203
2898
|
# The window as +model+'s loop would see it (EffectiveModel#context_window).
|
|
3204
2899
|
def current_context_window(model) = model.context_window
|
|
3205
2900
|
|
|
3206
|
-
# +settings+' chips (
|
|
3207
|
-
|
|
3208
|
-
def setting_chips(settings, model)
|
|
3209
|
-
settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
|
|
3210
|
-
end
|
|
2901
|
+
# +settings+' chips (EngineSnapshots#setting_chips).
|
|
2902
|
+
def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
|
|
3211
2903
|
|
|
3212
2904
|
# The effective model now, resolved on each call, never kept
|
|
3213
2905
|
# (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
|
|
3214
2906
|
# @return [EffectiveModel]
|
|
3215
|
-
def effective_model(choice = @
|
|
2907
|
+
def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
|
|
3216
2908
|
|
|
3217
2909
|
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
3218
2910
|
|
|
3219
|
-
#
|
|
3220
|
-
#
|
|
3221
|
-
def resolve_profile(choice)
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
EffectiveModel.of(@host_registry, choice).profile_resolution
|
|
3226
|
-
end
|
|
3227
|
-
|
|
3228
|
-
# Keeps +resolution+ as +choice+'s and applies it, unless the model
|
|
3229
|
-
# switched while it was resolved (a /stats on a Bridge thread probing
|
|
3230
|
-
# as a /model ran): then it is only returned, and the new model's is
|
|
3231
|
-
# resolved on its first need.
|
|
3232
|
-
# @return [ModelProfile::Resolution]
|
|
3233
|
-
def store_profile(choice, resolution)
|
|
3234
|
-
@profile_mutex.synchronize do
|
|
3235
|
-
return resolution unless @model_choice.equal?(choice)
|
|
3236
|
-
|
|
3237
|
-
apply_profile(resolution)
|
|
3238
|
-
@profile_resolution = [choice, resolution]
|
|
3239
|
-
end
|
|
3240
|
-
resolution
|
|
3241
|
-
end
|
|
3242
|
-
|
|
3243
|
-
# Everything that holds a profile follows the resolution: the kernel's
|
|
3244
|
-
# prompt format and parser, and the system prompts built for the old one.
|
|
3245
|
-
def apply_profile(resolution)
|
|
3246
|
-
kept = @profile_resolution&.last
|
|
3247
|
-
@prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
|
|
3248
|
-
@kernel.use_profile!(resolution)
|
|
3249
|
-
resolution
|
|
3250
|
-
end
|
|
3251
|
-
|
|
3252
|
-
# Before a turn: resolve now if nothing has yet, or again if the last
|
|
3253
|
-
# server probe failed (unreachable, or 503 while loading a model). A
|
|
3254
|
-
# profile that changes here drops the cached system prompts.
|
|
3255
|
-
def refresh_profile!
|
|
3256
|
-
choice, kept = @profile_resolution
|
|
3257
|
-
return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
|
|
3258
|
-
|
|
3259
|
-
store_profile(choice, resolve_profile(choice))
|
|
3260
|
-
end
|
|
2911
|
+
# The profile's resolve, keep, apply and retry are ModelBinding's
|
|
2912
|
+
# (#build_model_binding); these forward to it.
|
|
2913
|
+
def resolve_profile(choice) = @model_binding.resolve_profile(choice)
|
|
2914
|
+
def store_profile(choice, resolution) = @model_binding.store_profile(choice, resolution)
|
|
2915
|
+
def apply_profile(resolution) = @model_binding.apply_profile(resolution)
|
|
2916
|
+
def refresh_profile! = @model_binding.refresh_profile!
|
|
3261
2917
|
end
|
|
3262
2918
|
end
|