samagotchi 0.109.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 +57 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +9 -6
- data/docs/cli.md +43 -1
- data/docs/configuration.md +140 -3
- data/docs/internals/engine.md +58 -14
- data/docs/internals/participants.md +7 -4
- data/docs/internals/prompt-caching.md +5 -0
- 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/plugins.md +1 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +166 -499
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- 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/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -1369
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +41 -9
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- 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 +6 -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/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +25 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- 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/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +112 -32
- 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/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +30 -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"
|
|
@@ -78,6 +82,7 @@ require_relative "thinking"
|
|
|
78
82
|
require_relative "reasoning_history"
|
|
79
83
|
require_relative "answer_display"
|
|
80
84
|
require_relative "answer_refs"
|
|
85
|
+
require_relative "message_time"
|
|
81
86
|
require_relative "edit_preview"
|
|
82
87
|
require_relative "question_desk"
|
|
83
88
|
require_relative "question_asked"
|
|
@@ -141,6 +146,12 @@ module Samagotchi
|
|
|
141
146
|
# A message for the running turn cutting its generation.
|
|
142
147
|
@steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
|
|
143
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
|
|
144
155
|
@scratch = scratch
|
|
145
156
|
@no_interrupt = no_interrupt
|
|
146
157
|
@chat_backend = nil
|
|
@@ -151,22 +162,22 @@ module Samagotchi
|
|
|
151
162
|
# is used (the REPL checks the model it starts on, #switch_model!).
|
|
152
163
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
153
164
|
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
154
|
-
#
|
|
155
|
-
|
|
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.
|
|
156
169
|
typed = model_typed.to_s.strip
|
|
157
|
-
@
|
|
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
|
+
)
|
|
158
174
|
# The session's settings on its effective model (SessionModelSettings),
|
|
159
175
|
# read at use: /model moves the choice, #session= the session.
|
|
160
|
-
@model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @
|
|
176
|
+
@model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_binding.choice },
|
|
161
177
|
session: -> { @session })
|
|
162
178
|
# An injected client (specs) stands in for every host's client.
|
|
163
179
|
@host_registry.client_override = client if client
|
|
164
180
|
@client = effective_model.client
|
|
165
|
-
# A caller's profile pins it (until a model switch); otherwise
|
|
166
|
-
# #profile_resolution decides on first need (see there), so building an
|
|
167
|
-
# Engine makes no network call.
|
|
168
|
-
@given_profile = profile ? ModelProfile.normalize(profile) : nil
|
|
169
|
-
@profile_resolution = nil
|
|
170
181
|
# Ensure the built-in system bundle is installed (lazy, warn-only).
|
|
171
182
|
# This is the single seam for both TUI and non-TUI (web/worker) paths.
|
|
172
183
|
begin
|
|
@@ -253,6 +264,9 @@ module Samagotchi
|
|
|
253
264
|
# the single background thread and isolates per-job failures.
|
|
254
265
|
@idle_scheduler = build_idle_scheduler
|
|
255
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
|
|
256
270
|
end
|
|
257
271
|
|
|
258
272
|
# ── initialize's collaborators ──────────────────────────────────────────
|
|
@@ -337,7 +351,7 @@ module Samagotchi
|
|
|
337
351
|
def wire_kernel(kernel, muted_memories)
|
|
338
352
|
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
339
353
|
# the one built here does.
|
|
340
|
-
@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,
|
|
341
355
|
hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
|
|
342
356
|
@kernel.reminder_store = @reminder_store
|
|
343
357
|
@kernel.hooks = @hooks
|
|
@@ -382,7 +396,7 @@ module Samagotchi
|
|
|
382
396
|
hooks: -> { @hooks },
|
|
383
397
|
tools: -> { @tools },
|
|
384
398
|
session: -> { @session },
|
|
385
|
-
model_key: -> { @model_key },
|
|
399
|
+
model_key: -> { @model_binding.model_key },
|
|
386
400
|
model_name: -> { guardrail_model_name },
|
|
387
401
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
388
402
|
# An approval of the model's call: :question_asked first (QuestionAsked).
|
|
@@ -392,6 +406,74 @@ module Samagotchi
|
|
|
392
406
|
end
|
|
393
407
|
private :build_guardrail_wiring
|
|
394
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
|
+
|
|
395
477
|
# The question flow (see #initialize for when).
|
|
396
478
|
def build_question_desk
|
|
397
479
|
QuestionDesk.new(
|
|
@@ -585,9 +667,12 @@ module Samagotchi
|
|
|
585
667
|
|
|
586
668
|
# Emit a :recap_ready event (additive slot) carrying the generated recap
|
|
587
669
|
# and the generation id an observer uses to reject an invalidated recap.
|
|
588
|
-
# +covered+ counts the session messages it summarizes
|
|
589
|
-
|
|
590
|
-
|
|
670
|
+
# +covered+ counts the session messages it summarizes; +created_at+ is
|
|
671
|
+
# the saved recap's (UTC ISO; the UIs show its local time).
|
|
672
|
+
def emit_recap(recap:, generation:, covered: nil, created_at: nil)
|
|
673
|
+
event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
|
|
674
|
+
event[:created_at] = created_at if created_at
|
|
675
|
+
@session_observer.notify(event)
|
|
591
676
|
end
|
|
592
677
|
|
|
593
678
|
# Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
|
|
@@ -639,10 +724,10 @@ module Samagotchi
|
|
|
639
724
|
attr_reader :metrics
|
|
640
725
|
|
|
641
726
|
# The model as given (maybe an alias; HostRegistry#resolve applies it).
|
|
642
|
-
def effective_model_name = @
|
|
727
|
+
def effective_model_name = @model_binding.choice.name
|
|
643
728
|
|
|
644
729
|
# The model as typed at start or at the last /model.
|
|
645
|
-
def typed_model_name = @
|
|
730
|
+
def typed_model_name = @model_binding.choice.typed
|
|
646
731
|
|
|
647
732
|
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
648
733
|
# the alias applied, "host:id" when it names a host).
|
|
@@ -658,15 +743,15 @@ module Samagotchi
|
|
|
658
743
|
model = effective_model
|
|
659
744
|
entry = model.entry
|
|
660
745
|
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
661
|
-
SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
|
|
662
|
-
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)
|
|
663
748
|
end
|
|
664
749
|
|
|
665
750
|
# The model notes for the effective model (ModelNotes): matched on its
|
|
666
751
|
# bare id and key, the session's mutes left out.
|
|
667
752
|
# @return [Array<ModelNotes::Note>]
|
|
668
753
|
def model_notes
|
|
669
|
-
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,
|
|
670
755
|
muted: @muted_memory_names)
|
|
671
756
|
end
|
|
672
757
|
|
|
@@ -729,28 +814,17 @@ module Samagotchi
|
|
|
729
814
|
# The effective model's key (ModelOverlay.key_for its bare name): memory
|
|
730
815
|
# overlays, guardrail rules' models:.
|
|
731
816
|
# @return [String, nil]
|
|
732
|
-
|
|
817
|
+
def model_key = @model_binding.model_key
|
|
733
818
|
|
|
734
819
|
# The prompt profile for the effective model (see #profile_resolution).
|
|
735
820
|
# @return [ModelProfile]
|
|
736
821
|
def profile = profile_resolution.profile
|
|
737
822
|
|
|
738
|
-
# Which profile the effective model gets and where that came from
|
|
739
|
-
#
|
|
740
|
-
#
|
|
741
|
-
# and kept, so the system prompt and the server's KV prefix stay stable;
|
|
742
|
-
# #switch_model! starts over, and a failed server probe is retried before
|
|
743
|
-
# the next turn (#refresh_profile!). The kernel follows each resolution.
|
|
744
|
-
# Kept with the model choice it was resolved for: one that finishes
|
|
745
|
-
# 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).
|
|
746
826
|
# @return [ModelProfile::Resolution]
|
|
747
|
-
def profile_resolution
|
|
748
|
-
choice = @model_choice
|
|
749
|
-
cached = @profile_resolution
|
|
750
|
-
return cached.last if cached&.first.equal?(choice)
|
|
751
|
-
|
|
752
|
-
store_profile(choice, resolve_profile(choice))
|
|
753
|
-
end
|
|
827
|
+
def profile_resolution = @model_binding.profile_resolution
|
|
754
828
|
attr_reader :default_model_name, :host_registry, :client
|
|
755
829
|
|
|
756
830
|
# @return [Commands::Registry] the commands this session runs: the
|
|
@@ -765,17 +839,8 @@ module Samagotchi
|
|
|
765
839
|
@host_registry.bare_name(full_ref)
|
|
766
840
|
end
|
|
767
841
|
|
|
768
|
-
# The memory overlay
|
|
769
|
-
|
|
770
|
-
# (before aliases were applied there): that key is read when the
|
|
771
|
-
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
772
|
-
def sync_model_key!
|
|
773
|
-
choice = @model_choice
|
|
774
|
-
@model_key = ModelOverlay.key_for(bare_model_name(choice.name))
|
|
775
|
-
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
|
|
776
|
-
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
777
|
-
@kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
778
|
-
end
|
|
842
|
+
# The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
|
|
843
|
+
def sync_model_key! = @model_binding.sync_model_key!
|
|
779
844
|
private :sync_model_key!
|
|
780
845
|
|
|
781
846
|
# The effective model's bare name for the guardrails' models: rules;
|
|
@@ -1085,13 +1150,7 @@ module Samagotchi
|
|
|
1085
1150
|
# once; checked here: an unknown host, an alias naming another host.
|
|
1086
1151
|
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
1087
1152
|
choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
|
|
1088
|
-
@
|
|
1089
|
-
# One assignment: a reader on another thread sees the old pair or the new one.
|
|
1090
|
-
@model_choice = choice
|
|
1091
|
-
# A profile given to .new was for the starting model.
|
|
1092
|
-
@given_profile = nil
|
|
1093
|
-
@profile_resolution = nil
|
|
1094
|
-
end
|
|
1153
|
+
@model_binding.switch!(choice)
|
|
1095
1154
|
sync_model_key!
|
|
1096
1155
|
@prompt_builder.reset!
|
|
1097
1156
|
# The notes the new model's prompt loads at its next build, so /model,
|
|
@@ -1192,53 +1251,10 @@ module Samagotchi
|
|
|
1192
1251
|
# @return [ReminderStore] the reminder store for inspection
|
|
1193
1252
|
attr_reader :reminder_store
|
|
1194
1253
|
|
|
1195
|
-
# The metrics for /stats
|
|
1196
|
-
#
|
|
1197
|
-
# profile come from the effective model's server, so this may make one
|
|
1198
|
-
# short /props GET; the cheap #session_state_snapshot never does.
|
|
1199
|
-
# price_host: the effective model's host, whose hosts.<name>.models
|
|
1200
|
-
# prices the cost estimate names. kit_prompt: the next turn's kit
|
|
1201
|
-
# prompt text ({kit:, tokens:}), only when its toolkit has one.
|
|
1202
|
-
# settings: the session's own settings ({"recap.enabled" => false}),
|
|
1203
|
-
# only when it has some.
|
|
1254
|
+
# The metrics for /stats, which may make one short /props GET
|
|
1255
|
+
# (EngineSnapshots#stats_snapshot).
|
|
1204
1256
|
# @return [Hash] @metrics.snapshot, filled in
|
|
1205
|
-
def stats_snapshot
|
|
1206
|
-
snapshot = @metrics.snapshot
|
|
1207
|
-
# One resolve for the whole snapshot (its readers' rescues stay theirs);
|
|
1208
|
-
# a model that doesn't resolve raises here, as it always has.
|
|
1209
|
-
model = effective_model
|
|
1210
|
-
served, served_for = served_model_for(snapshot, model: model)
|
|
1211
|
-
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1212
|
-
served_expected_by: model.served_expected_by(served),
|
|
1213
|
-
**setting_chips(SessionSetting::STATS, model),
|
|
1214
|
-
prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
|
|
1215
|
-
price_host: model.host_name)
|
|
1216
|
-
kit_prompt = kit_prompt_stats
|
|
1217
|
-
snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
|
|
1218
|
-
# Each participant's spend under their name (participants.json's).
|
|
1219
|
-
if (spend = snapshot[:spend_by_author])
|
|
1220
|
-
named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
|
|
1221
|
-
snapshot = snapshot.merge(spend_by_author: named)
|
|
1222
|
-
end
|
|
1223
|
-
# The session's own settings (chi --set), as --set names them.
|
|
1224
|
-
settings = @session&.settings&.flat || {}
|
|
1225
|
-
snapshot = snapshot.merge(settings: settings) unless settings.empty?
|
|
1226
|
-
unless snapshot.dig(:context, :window_tokens)
|
|
1227
|
-
window = current_context_window(model)
|
|
1228
|
-
if window
|
|
1229
|
-
snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
|
|
1230
|
-
window_source: window.source.to_s))
|
|
1231
|
-
end
|
|
1232
|
-
end
|
|
1233
|
-
# The chat loop uses no prompt profile: drop one a native turn reported.
|
|
1234
|
-
return snapshot.except(:profile, :profile_source) if model.chat?
|
|
1235
|
-
|
|
1236
|
-
unless snapshot[:profile]
|
|
1237
|
-
resolution = profile_resolution
|
|
1238
|
-
snapshot = snapshot.merge(profile: resolution.profile.name, profile_source: resolution.label)
|
|
1239
|
-
end
|
|
1240
|
-
snapshot
|
|
1241
|
-
end
|
|
1257
|
+
def stats_snapshot = @engine_snapshots.stats_snapshot
|
|
1242
1258
|
|
|
1243
1259
|
# ── The session's model settings (SessionModelSettings) ──────────────────
|
|
1244
1260
|
|
|
@@ -1309,67 +1325,14 @@ module Samagotchi
|
|
|
1309
1325
|
# else [nil, nil].
|
|
1310
1326
|
# @return [Array(String, String), Array(nil, nil)]
|
|
1311
1327
|
def served_model(probe: true)
|
|
1312
|
-
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)
|
|
1313
1329
|
end
|
|
1314
1330
|
|
|
1315
1331
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
1316
|
-
# live event sequence
|
|
1317
|
-
#
|
|
1318
|
-
#
|
|
1319
|
-
|
|
1320
|
-
# @return [Hash] with keys:
|
|
1321
|
-
# :status [String, nil] current session status
|
|
1322
|
-
# :message_count [Integer] number of messages in the session
|
|
1323
|
-
# :last_prompt [String, nil] the last user prompt (empty string if none)
|
|
1324
|
-
# :event_seq [Integer] @session_observer.event_count
|
|
1325
|
-
# :metrics [Hash] @metrics.snapshot (per-session analytics)
|
|
1326
|
-
# :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
|
|
1327
|
-
# :used_memory_names [Array<String>] deduped memory names active this session
|
|
1328
|
-
# :prompt_notes [Array<Hash>] the model notes the prompt carried
|
|
1329
|
-
# ({name:, scope:, chars:, digest:}; #prompt_notes)
|
|
1330
|
-
# :parent_id [String, nil] the session that delegated this one
|
|
1331
|
-
# :model_name [String] the model turns run on now (after /model)
|
|
1332
|
-
# :served_model, :served_model_for [String, nil] what a generation of
|
|
1333
|
-
# that model reported serving, and the name asked (#served_model
|
|
1334
|
-
# without the probe)
|
|
1335
|
-
# :served_expected_by [String, nil] the host whose models: entry names
|
|
1336
|
-
# that served model under served: (#served_expected_by)
|
|
1337
|
-
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1338
|
-
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1339
|
-
# :participants [Array<Hash>] who is in the review now ({id:, name:,
|
|
1340
|
-
# kind:}; ParticipantsWatch#active), [] for nobody
|
|
1341
|
-
def session_state_snapshot
|
|
1342
|
-
served_pair = served_model(probe: false)
|
|
1343
|
-
metrics = @metrics.snapshot
|
|
1344
|
-
# One resolve for the model's fields; nil when it doesn't resolve (the
|
|
1345
|
-
# Bridge's /state and reset frames rely on this never raising): those
|
|
1346
|
-
# fields are nil then, as their own rescues always gave.
|
|
1347
|
-
model = @model_settings.model_setting(nil) { |resolved| resolved }
|
|
1348
|
-
{
|
|
1349
|
-
status: @session&.status,
|
|
1350
|
-
message_count: (@session&.messages || []).size,
|
|
1351
|
-
last_prompt: @session&.last_prompt,
|
|
1352
|
-
event_seq: @session_observer&.event_count,
|
|
1353
|
-
metrics: metrics,
|
|
1354
|
-
pending_question: @question_desk.pending&.to_h,
|
|
1355
|
-
used_memory_names: @used_memories.names,
|
|
1356
|
-
preloaded_memory_names: preloaded_memory_names,
|
|
1357
|
-
muted_memory_names: @muted_memory_names.dup,
|
|
1358
|
-
prompt_notes: prompt_notes.map(&:to_h),
|
|
1359
|
-
parent_id: @session&.parent_id,
|
|
1360
|
-
title: @session&.title,
|
|
1361
|
-
model_name: effective_model_ref,
|
|
1362
|
-
served_model: served_pair[0],
|
|
1363
|
-
served_model_for: served_pair[1],
|
|
1364
|
-
served_expected_by: model&.served_expected_by(served_pair[0]),
|
|
1365
|
-
context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
|
|
1366
|
-
**setting_chips(SessionSetting::ALL, model),
|
|
1367
|
-
recap_enabled: !@recap.nil?,
|
|
1368
|
-
recap_min_user_turns: @recap&.min_user_turns,
|
|
1369
|
-
recap_inactivity_seconds: @recap&.inactivity&.to_i,
|
|
1370
|
-
participants: @participants_watch.active
|
|
1371
|
-
}
|
|
1372
|
-
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
|
|
1373
1336
|
|
|
1374
1337
|
# @return [Array<String>] deduped used memory names (thread-safe copy)
|
|
1375
1338
|
def used_memory_names
|
|
@@ -1547,20 +1510,8 @@ module Samagotchi
|
|
|
1547
1510
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1548
1511
|
end
|
|
1549
1512
|
|
|
1550
|
-
# Why a turn stopped
|
|
1551
|
-
|
|
1552
|
-
# a retry may help, a cancel's reason and the hook that stopped it.
|
|
1553
|
-
# Only what is known.
|
|
1554
|
-
def stop_facts(event)
|
|
1555
|
-
case event&.dig(:type)
|
|
1556
|
-
# kept_steps: the turn's work stayed (a parent must not send the task again).
|
|
1557
|
-
when :turn_failed
|
|
1558
|
-
{ error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
|
|
1559
|
-
when :turn_canceled
|
|
1560
|
-
{ cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
|
|
1561
|
-
else {}
|
|
1562
|
-
end
|
|
1563
|
-
end
|
|
1513
|
+
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1514
|
+
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1564
1515
|
private :stop_facts
|
|
1565
1516
|
|
|
1566
1517
|
# How every turn ends (completed, canceled, failed): the session gets
|
|
@@ -1997,8 +1948,10 @@ module Samagotchi
|
|
|
1997
1948
|
# +resume_from+: where its reasoning starts, the run it resumes
|
|
1998
1949
|
# (run_turn's resume_from:); nil for every other turn, and for a
|
|
1999
1950
|
# Continue that starts cold (after a model change).
|
|
1951
|
+
# +wall+: the wall time it started (MessageTime.now, once): the
|
|
1952
|
+
# :turn_started event's at and its user message's (and reminders') stamp.
|
|
2000
1953
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
2001
|
-
:settings, :id, :limit, :resumed, :resume_from) do
|
|
1954
|
+
:settings, :id, :limit, :resumed, :resume_from, :wall) do
|
|
2002
1955
|
def resumed? = resumed ? true : false
|
|
2003
1956
|
|
|
2004
1957
|
# The turn's boundary events carry the origin only when it names a
|
|
@@ -2009,6 +1962,9 @@ module Samagotchi
|
|
|
2009
1962
|
|
|
2010
1963
|
# Seconds since the turn started (the cancel note says how long it ran).
|
|
2011
1964
|
def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
1965
|
+
|
|
1966
|
+
# The wall time's stamp text (MessageTime::KEY).
|
|
1967
|
+
def at = MessageTime.iso(wall)
|
|
2012
1968
|
end
|
|
2013
1969
|
private_constant :Turn
|
|
2014
1970
|
|
|
@@ -2141,7 +2097,7 @@ module Samagotchi
|
|
|
2141
2097
|
|
|
2142
2098
|
Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
|
|
2143
2099
|
controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
2144
|
-
id: id || SecureRandom.uuid)
|
|
2100
|
+
id: id || SecureRandom.uuid, wall: MessageTime.now)
|
|
2145
2101
|
end
|
|
2146
2102
|
private :begin_turn
|
|
2147
2103
|
|
|
@@ -2155,7 +2111,7 @@ module Samagotchi
|
|
|
2155
2111
|
image_refs, image_error = turn_image_refs(session, images)
|
|
2156
2112
|
# Emit turn_started event
|
|
2157
2113
|
bind_metrics(session)
|
|
2158
|
-
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
2114
|
+
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id, at: turn.at }
|
|
2159
2115
|
turn_started[:continue] = true if turn.continue
|
|
2160
2116
|
turn_started[:resumed] = true if turn.resumed?
|
|
2161
2117
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
@@ -2316,16 +2272,18 @@ module Samagotchi
|
|
|
2316
2272
|
# system prompt invalidates cache for the entire prefix.
|
|
2317
2273
|
# Not into a participant's turn: the owner's reminders are the owner's,
|
|
2318
2274
|
# and would be used up there under the review boundary.
|
|
2319
|
-
due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages)
|
|
2275
|
+
due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages, now: turn.wall)
|
|
2320
2276
|
if due_reminders.any?
|
|
2321
2277
|
emit_event(turn.on_event, {
|
|
2322
2278
|
type: :reminder_injected,
|
|
2323
|
-
reminders: due_reminders
|
|
2279
|
+
reminders: due_reminders,
|
|
2280
|
+
at: turn.at
|
|
2324
2281
|
})
|
|
2325
2282
|
end
|
|
2326
2283
|
|
|
2327
2284
|
unless turn.continue
|
|
2328
|
-
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author)
|
|
2285
|
+
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2286
|
+
MessageTime::KEY => turn.at }
|
|
2329
2287
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2330
2288
|
turn.messages << user_message
|
|
2331
2289
|
session.last_prompt = turn.prompt
|
|
@@ -2475,89 +2433,17 @@ module Samagotchi
|
|
|
2475
2433
|
end
|
|
2476
2434
|
private :complete_turn
|
|
2477
2435
|
|
|
2478
|
-
# What
|
|
2479
|
-
#
|
|
2480
|
-
|
|
2481
|
-
# A turn that ran out of iterations ends at its tool results, so a
|
|
2482
|
-
# continue resumes from them rather than after a made-up reply (and a
|
|
2483
|
-
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
2484
|
-
def kept_messages(turn, result, seconds)
|
|
2485
|
-
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
2486
|
-
# Nothing visible (no text) is a turn the model should know ended
|
|
2487
|
-
# that way: the note, and nothing made up as its answer. The note's
|
|
2488
|
-
# marker is for the UIs (the notice, and the empty steps after a
|
|
2489
|
-
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
2490
|
-
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
2491
|
-
# turn's note stays: it says why that turn ended.
|
|
2492
|
-
if result.empty_answer?
|
|
2493
|
-
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
2494
|
-
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
2495
|
-
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
2496
|
-
conversation << note if conversation
|
|
2497
|
-
saved + [note]
|
|
2498
|
-
elsif result.canceled? && conversation
|
|
2499
|
-
conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
|
|
2500
|
-
stopped_by: turn.controller.detail,
|
|
2501
|
-
shown: TurnNote.interrupted_tail?(conversation),
|
|
2502
|
-
running_tasks: Tools::TaskRuntime.running_created_in(conversation))
|
|
2503
|
-
else
|
|
2504
|
-
conversation
|
|
2505
|
-
end
|
|
2506
|
-
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)
|
|
2507
2439
|
private :kept_messages
|
|
2508
|
-
|
|
2509
|
-
# A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
|
|
2510
|
-
# finished tool iterations, every call answered: ToolResponse.run_batch),
|
|
2511
|
-
# else the turn's messages so far (it came before the loop), with a
|
|
2512
|
-
# cancel note naming the tasks still running; nil before there are any.
|
|
2513
|
-
def ctrl_c_messages(turn, interrupt, seconds)
|
|
2514
|
-
kept = loop_conversation(interrupt) || turn.messages
|
|
2515
|
-
return nil unless kept
|
|
2516
|
-
|
|
2517
|
-
note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
|
|
2518
|
-
TurnNote.replace_trailing(kept, note)
|
|
2519
|
-
end
|
|
2440
|
+
def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
|
|
2520
2441
|
private :ctrl_c_messages
|
|
2521
|
-
|
|
2522
|
-
# The conversation the loop had built when +error+ ended the turn
|
|
2523
|
-
# (LLM::FailedTurn), or nil.
|
|
2524
|
-
def loop_conversation(error)
|
|
2525
|
-
error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
|
|
2526
|
-
end
|
|
2442
|
+
def loop_conversation(error) = @turn_endings.loop_conversation(error)
|
|
2527
2443
|
private :loop_conversation
|
|
2528
|
-
|
|
2529
|
-
# A failure keeps the loop's partial conversation (else the turn's
|
|
2530
|
-
# messages so far) with a failed note; nil when the turn never reached
|
|
2531
|
-
# the model. The model reads why on its next turn. A turn that got to
|
|
2532
|
-
# tool results (LLM::FailedTurn.progress) says so in the note; on the
|
|
2533
|
-
# error (kept_steps) too unless it failed in a way that rolls back
|
|
2534
|
-
# (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
|
|
2535
|
-
# turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
|
|
2536
|
-
# note of their own; -p --non-interactive keeps what this keeps.
|
|
2537
|
-
def failed_messages(turn, error)
|
|
2538
|
-
kept = loop_conversation(error) || turn.messages
|
|
2539
|
-
return nil unless kept
|
|
2540
|
-
|
|
2541
|
-
steps = LLM::FailedTurn.progress(turn.session.messages, kept)
|
|
2542
|
-
error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
|
|
2543
|
-
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
2544
|
-
TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
|
|
2545
|
-
end
|
|
2444
|
+
def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
|
|
2546
2445
|
private :failed_messages
|
|
2547
|
-
|
|
2548
|
-
# Called after #failed_messages, which marks the error's kept steps.
|
|
2549
|
-
def failed_event(error, seconds)
|
|
2550
|
-
failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
|
|
2551
|
-
duration_ms: (seconds * 1000).round }
|
|
2552
|
-
# Its work stays: the UIs say so, as after a cancel.
|
|
2553
|
-
steps = LLM::FailedTurn.kept_steps(error)
|
|
2554
|
-
failed[:kept_steps] = steps if steps
|
|
2555
|
-
# A provider error says what kind it is, for one line per kind in the UIs.
|
|
2556
|
-
if error.is_a?(LLM::ProviderError)
|
|
2557
|
-
failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
|
|
2558
|
-
end
|
|
2559
|
-
failed
|
|
2560
|
-
end
|
|
2446
|
+
def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
|
|
2561
2447
|
private :failed_event
|
|
2562
2448
|
|
|
2563
2449
|
# However the turn ended (#run_turn's ensure): the turn flag off, the
|
|
@@ -2615,23 +2501,10 @@ module Samagotchi
|
|
|
2615
2501
|
raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
|
|
2616
2502
|
end
|
|
2617
2503
|
|
|
2618
|
-
# JSON-safe digest of a finished turn for renderers (
|
|
2619
|
-
# 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).
|
|
2620
2505
|
# @param result [LLM::ModelResult]
|
|
2621
2506
|
# @return [Hash]
|
|
2622
|
-
def turn_summary(result)
|
|
2623
|
-
summary = {
|
|
2624
|
-
output: result.output.to_s,
|
|
2625
|
-
exhausted: result.exhausted?,
|
|
2626
|
-
resumable: result.resumable?,
|
|
2627
|
-
pending_tool_calls: result.pending_tool_calls?,
|
|
2628
|
-
tool_activity: Array(result.tool_activity).map(&:dup),
|
|
2629
|
-
context_status: result.context_status&.dup
|
|
2630
|
-
}
|
|
2631
|
-
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
2632
|
-
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
2633
|
-
summary
|
|
2634
|
-
end
|
|
2507
|
+
def turn_summary(result) = @turn_endings.turn_summary(result)
|
|
2635
2508
|
|
|
2636
2509
|
# ── Session messages API ───────────────────────────────────────────────
|
|
2637
2510
|
#
|
|
@@ -2882,7 +2755,7 @@ module Samagotchi
|
|
|
2882
2755
|
model: model, source: source)
|
|
2883
2756
|
},
|
|
2884
2757
|
model_name: -> { effective_model_ref },
|
|
2885
|
-
model_key: -> { @model_key },
|
|
2758
|
+
model_key: -> { @model_binding.model_key },
|
|
2886
2759
|
state_dir: -> { session_state_dir },
|
|
2887
2760
|
scratch: -> { @scratch },
|
|
2888
2761
|
setup: -> { SessionSetup.of(@session) },
|
|
@@ -2999,253 +2872,47 @@ module Samagotchi
|
|
|
2999
2872
|
|
|
3000
2873
|
# ── Event helpers ──────────────────────────────────────────────────────────
|
|
3001
2874
|
|
|
3002
|
-
#
|
|
3003
|
-
#
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
# only notifying the observer.
|
|
3007
|
-
# Wrap a raw kernel stream event and fan it out to the turn sink + observers.
|
|
3008
|
-
#
|
|
3009
|
-
# Each :generation_chunk comes split already, by the loop that streamed it:
|
|
3010
|
-
# besides the raw `content` (left untouched — the TUI thinking spinner,
|
|
3011
|
-
# analytics, and Bridge replay rely on it) it carries
|
|
3012
|
-
# * :text — visible prose (thinking AND tool_call blocks removed)
|
|
3013
|
-
# * :thinking — thinking-only content
|
|
3014
|
-
# KernelLoop splits a native stream with a ThoughtStreamSplitter per
|
|
3015
|
-
# generation (every profile, Gemma's thought channel included); the chat
|
|
3016
|
-
# loop gets reasoning apart from the answer. The web routes on those
|
|
3017
|
-
# fields (chunk_router.js).
|
|
3018
|
-
#
|
|
3019
|
-
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
3020
|
-
# each chunk's thinking and text after the UIs had it.
|
|
3021
|
-
#
|
|
3022
|
-
# +model_ref+: the turn's model (#generation_model_ref), added to each
|
|
3023
|
-
# :generation_started as `model_ref:` (both loops send through here).
|
|
3024
|
-
def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
|
|
3025
|
-
watch = stream_watch(cancel_controller)
|
|
3026
|
-
proc do |event|
|
|
3027
|
-
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
3028
|
-
@steer_cut.observe(event, progress)
|
|
3029
|
-
case event[:type]
|
|
3030
|
-
when :generation_started
|
|
3031
|
-
event = event.merge(model_ref: model_ref) if model_ref
|
|
3032
|
-
@thinking_notices.generation_started
|
|
3033
|
-
watch&.started(event[:iteration])
|
|
3034
|
-
when :generation_retrying
|
|
3035
|
-
# A dropped stream's step asked again: it streams from the start.
|
|
3036
|
-
if event[:restarted]
|
|
3037
|
-
@thinking_notices.generation_started
|
|
3038
|
-
watch&.started(event[:iteration], restarted: true)
|
|
3039
|
-
end
|
|
3040
|
-
when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
|
|
3041
|
-
end
|
|
3042
|
-
# The chat loop asked again without the thinking fields or the
|
|
3043
|
-
# reasoning sent back: a notice, not an event of its own.
|
|
3044
|
-
next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
|
|
3045
|
-
next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
3046
|
-
next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
|
|
3047
|
-
|
|
3048
|
-
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
3049
|
-
emit_event(on_event, event)
|
|
3050
|
-
# What this request sends: both loops start a generation after
|
|
3051
|
-
# their apply_llm_context!.
|
|
3052
|
-
emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
|
|
3053
|
-
watch.feed(**progress) if watch && progress
|
|
3054
|
-
@thinking_notices.generation_completed(event) if event[:type] == :generation_completed
|
|
3055
|
-
end
|
|
3056
|
-
end
|
|
3057
|
-
|
|
3058
|
-
# The working set (WorkingSet) as the model has it, as a
|
|
3059
|
-
# :working_set_updated event ({items:, at:}, adds: while the user's
|
|
3060
|
-
# adds are pending): at each request (the
|
|
3061
|
-
# stream handler's :generation_started, from the running turn's
|
|
3062
|
-
# conversation) and at turn end (#end_turn, from the session's kept
|
|
3063
|
-
# messages). Only when it differs from the last one emitted for this
|
|
3064
|
-
# session, and never while it is empty — except one empty event,
|
|
3065
|
-
# remembered, when the last one for this session had items (a
|
|
3066
|
-
# rollback removed the tool calls), so a tool-free turn emits none
|
|
3067
|
-
# and the next empty request sends nothing more. The web overlays it
|
|
3068
|
-
# on the rows' set; other UIs ignore it.
|
|
3069
|
-
#
|
|
3070
|
-
# Between turns (#announce_working_set: no +on_event+) it is
|
|
3071
|
-
# announced, under the next turn's strategy.
|
|
3072
|
-
def emit_working_set(on_event, session, conversation)
|
|
3073
|
-
return unless session && conversation
|
|
3074
|
-
|
|
3075
|
-
snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
|
|
3076
|
-
pins: session.working_set_pins)
|
|
3077
|
-
|
|
3078
|
-
shown = snapshot.to_h.except(:at)
|
|
3079
|
-
last = @working_set_emitted
|
|
3080
|
-
if snapshot.empty?
|
|
3081
|
-
return unless last && last[0] == session.id && last[1] != { items: [] }
|
|
3082
|
-
elsif last == [session.id, shown]
|
|
3083
|
-
return
|
|
3084
|
-
end
|
|
3085
|
-
|
|
3086
|
-
@working_set_emitted = [session.id, shown]
|
|
3087
|
-
event = { type: :working_set_updated, **shown, at: snapshot.at }
|
|
3088
|
-
on_event ? emit_event(on_event, event) : announce_or_hold(event)
|
|
3089
|
-
rescue StandardError => e
|
|
3090
|
-
Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3091
|
-
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(...)
|
|
3092
2879
|
private :emit_working_set
|
|
3093
|
-
|
|
3094
|
-
# The strategy the set is built under: the running turn's
|
|
3095
|
-
# (TurnSettings#llm_context: what its requests send); between turns
|
|
3096
|
-
# the next turn's (as #warm_up_next_turn: the turn's settings keep the
|
|
3097
|
-
# last turn's, stale after a /llm-context or /model change), the last
|
|
3098
|
-
# turn's when it can't be resolved.
|
|
3099
|
-
def working_set_llm_context
|
|
3100
|
-
turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
|
|
3101
|
-
return turn if turn_running?
|
|
3102
|
-
|
|
3103
|
-
@model_settings.turn_llm_context || turn
|
|
3104
|
-
end
|
|
2880
|
+
def working_set_llm_context = @event_fanout.working_set_llm_context
|
|
3105
2881
|
private :working_set_llm_context
|
|
3106
|
-
|
|
3107
|
-
# A :generation_completed with the generation's decode speed (`speed:`
|
|
3108
|
-
# {decode_tps:, source:}, nil without one), the session's running
|
|
3109
|
-
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
3110
|
-
# (`model_segments:`, the snapshot's) and whether its served
|
|
3111
|
-
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
3112
|
-
# reads them from the stream. SessionMetrics closes the generation here,
|
|
3113
|
-
# so the event it gets next finds it closed.
|
|
3114
|
-
def with_generation_report(event)
|
|
3115
|
-
report = @metrics.finish_generation
|
|
3116
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
|
|
3117
|
-
served_expected_by: served_expected_by(event[:served_model]))
|
|
3118
|
-
rescue StandardError => e
|
|
3119
|
-
Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3120
|
-
event
|
|
3121
|
-
end
|
|
2882
|
+
def with_generation_report(event) = @event_fanout.with_generation_report(event)
|
|
3122
2883
|
private :with_generation_report
|
|
3123
|
-
|
|
3124
|
-
# The turn's StreamWatch, or nil when no hook listens.
|
|
3125
|
-
def stream_watch(cancel_controller)
|
|
3126
|
-
return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
|
|
3127
|
-
|
|
3128
|
-
Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
|
|
3129
|
-
end
|
|
2884
|
+
def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
|
|
3130
2885
|
private :stream_watch
|
|
3131
|
-
|
|
3132
|
-
def emit_event(on_event, event)
|
|
3133
|
-
# Capture used memories synchronously in the turn thread.
|
|
3134
|
-
read_names = begin
|
|
3135
|
-
@used_memories.capture(event, muted: @muted_memory_names)
|
|
3136
|
-
rescue StandardError => e
|
|
3137
|
-
Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3138
|
-
nil
|
|
3139
|
-
end
|
|
3140
|
-
# Turn-scoped sink: receives the event hash as the stream handler
|
|
3141
|
-
# passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
|
|
3142
|
-
# kernel loop (same as KernelLoop's own handling).
|
|
3143
|
-
if on_event
|
|
3144
|
-
begin
|
|
3145
|
-
on_event.call(event)
|
|
3146
|
-
rescue StandardError => e
|
|
3147
|
-
# Sink errors must not break the kernel loop (same as KernelLoop's own handling)
|
|
3148
|
-
Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
|
|
3149
|
-
end
|
|
3150
|
-
end
|
|
3151
|
-
|
|
3152
|
-
# Persistent subscribers: receive a copy with a locally-monotonic
|
|
3153
|
-
# `event_seq`, fan out with per-subscriber error isolation.
|
|
3154
|
-
@session_observer.notify(event)
|
|
3155
|
-
# Every memory read, after its tool_call_started: the whole list for
|
|
3156
|
-
# the UIs' memory line, and the names this call read (read_names).
|
|
3157
|
-
return unless read_names
|
|
3158
|
-
|
|
3159
|
-
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
|
|
3160
|
-
end
|
|
2886
|
+
def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
|
|
3161
2887
|
|
|
3162
2888
|
# The effective model's window_tokens and price, read per turn
|
|
3163
2889
|
# (SessionModelSettings).
|
|
3164
2890
|
def turn_window_setting = @model_settings.turn_window_setting
|
|
3165
2891
|
def turn_price = @model_settings.turn_price
|
|
3166
2892
|
|
|
3167
|
-
#
|
|
3168
|
-
#
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
def served_model_for(snapshot, model: nil)
|
|
3172
|
-
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
3173
|
-
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
3174
|
-
return [nil, nil] unless model
|
|
3175
|
-
|
|
3176
|
-
model.served_from_props(asked)
|
|
3177
|
-
# Only bare_model_name and the snapshot lookup are left to fail here:
|
|
3178
|
-
# the probe's failure is EffectiveModel#served_from_props's.
|
|
3179
|
-
rescue StandardError
|
|
3180
|
-
[nil, nil]
|
|
3181
|
-
end
|
|
3182
|
-
|
|
3183
|
-
# The next turn's kit prompt text for /stats: its kit and its estimated
|
|
3184
|
-
# tokens (chars / 4), nil when its toolkit has none.
|
|
3185
|
-
def kit_prompt_stats
|
|
3186
|
-
resolved = toolkit_explained.resolved
|
|
3187
|
-
return nil unless resolved.prompt
|
|
3188
|
-
|
|
3189
|
-
{ kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
|
|
3190
|
-
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
|
|
3191
2897
|
|
|
3192
2898
|
# The window as +model+'s loop would see it (EffectiveModel#context_window).
|
|
3193
2899
|
def current_context_window(model) = model.context_window
|
|
3194
2900
|
|
|
3195
|
-
# +settings+' chips (
|
|
3196
|
-
|
|
3197
|
-
def setting_chips(settings, model)
|
|
3198
|
-
settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
|
|
3199
|
-
end
|
|
2901
|
+
# +settings+' chips (EngineSnapshots#setting_chips).
|
|
2902
|
+
def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
|
|
3200
2903
|
|
|
3201
2904
|
# The effective model now, resolved on each call, never kept
|
|
3202
2905
|
# (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
|
|
3203
2906
|
# @return [EffectiveModel]
|
|
3204
|
-
def effective_model(choice = @
|
|
2907
|
+
def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
|
|
3205
2908
|
|
|
3206
2909
|
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
3207
2910
|
|
|
3208
|
-
#
|
|
3209
|
-
#
|
|
3210
|
-
def resolve_profile(choice)
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
EffectiveModel.of(@host_registry, choice).profile_resolution
|
|
3215
|
-
end
|
|
3216
|
-
|
|
3217
|
-
# Keeps +resolution+ as +choice+'s and applies it, unless the model
|
|
3218
|
-
# switched while it was resolved (a /stats on a Bridge thread probing
|
|
3219
|
-
# as a /model ran): then it is only returned, and the new model's is
|
|
3220
|
-
# resolved on its first need.
|
|
3221
|
-
# @return [ModelProfile::Resolution]
|
|
3222
|
-
def store_profile(choice, resolution)
|
|
3223
|
-
@profile_mutex.synchronize do
|
|
3224
|
-
return resolution unless @model_choice.equal?(choice)
|
|
3225
|
-
|
|
3226
|
-
apply_profile(resolution)
|
|
3227
|
-
@profile_resolution = [choice, resolution]
|
|
3228
|
-
end
|
|
3229
|
-
resolution
|
|
3230
|
-
end
|
|
3231
|
-
|
|
3232
|
-
# Everything that holds a profile follows the resolution: the kernel's
|
|
3233
|
-
# prompt format and parser, and the system prompts built for the old one.
|
|
3234
|
-
def apply_profile(resolution)
|
|
3235
|
-
kept = @profile_resolution&.last
|
|
3236
|
-
@prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
|
|
3237
|
-
@kernel.use_profile!(resolution)
|
|
3238
|
-
resolution
|
|
3239
|
-
end
|
|
3240
|
-
|
|
3241
|
-
# Before a turn: resolve now if nothing has yet, or again if the last
|
|
3242
|
-
# server probe failed (unreachable, or 503 while loading a model). A
|
|
3243
|
-
# profile that changes here drops the cached system prompts.
|
|
3244
|
-
def refresh_profile!
|
|
3245
|
-
choice, kept = @profile_resolution
|
|
3246
|
-
return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
|
|
3247
|
-
|
|
3248
|
-
store_profile(choice, resolve_profile(choice))
|
|
3249
|
-
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!
|
|
3250
2917
|
end
|
|
3251
2918
|
end
|