samagotchi 0.67.0 → 0.69.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 +62 -1
- data/docs/architecture.md +7 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +28 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +4 -0
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +73 -17
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/plugins.md +42 -19
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +3 -3
- data/docs/testing.md +8 -2
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +0 -19
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +216 -360
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +24 -7
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +6 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +24 -3
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +69 -93
- data/lib/samagotchi/web/message_parts.rb +5 -2
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +196 -526
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +21 -11
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +47 -28
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +15 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -14,6 +14,9 @@ require_relative "context_status"
|
|
|
14
14
|
require_relative "steer"
|
|
15
15
|
require_relative "turn_note"
|
|
16
16
|
require_relative "model_profile"
|
|
17
|
+
require_relative "effective_model"
|
|
18
|
+
require_relative "session_setting"
|
|
19
|
+
require_relative "thinking_notices"
|
|
17
20
|
require_relative "cancellation_controller"
|
|
18
21
|
require_relative "context_window"
|
|
19
22
|
require_relative "kernel_loop"
|
|
@@ -134,15 +137,17 @@ module Samagotchi
|
|
|
134
137
|
# is used (the REPL checks the model it starts on, #switch_model!).
|
|
135
138
|
@default_model_name = ModelProfile.required_model_name(model_name)
|
|
136
139
|
ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
|
|
137
|
-
|
|
140
|
+
# Guards the model choice and the profile cached for it (#store_profile).
|
|
141
|
+
@profile_mutex = Mutex.new
|
|
142
|
+
typed = model_typed.to_s.strip
|
|
143
|
+
@model_choice = ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed)
|
|
138
144
|
# An injected client (specs) stands in for every host's client.
|
|
139
145
|
@host_registry.client_override = client if client
|
|
140
|
-
@client =
|
|
146
|
+
@client = effective_model.client
|
|
141
147
|
# A caller's profile pins it (until a model switch); otherwise
|
|
142
148
|
# #profile_resolution decides on first need (see there), so building an
|
|
143
149
|
# Engine makes no network call.
|
|
144
150
|
@given_profile = profile ? ModelProfile.normalize(profile) : nil
|
|
145
|
-
@typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
|
|
146
151
|
@profile_resolution = nil
|
|
147
152
|
# Ensure the built-in system bundle is installed (lazy, warn-only).
|
|
148
153
|
# This is the single seam for both TUI and non-TUI (web/worker) paths.
|
|
@@ -195,6 +200,9 @@ module Samagotchi
|
|
|
195
200
|
# The session's toolkit: the kits (the plugins' too, chi.toolkit),
|
|
196
201
|
# what its next turn declares, the turn-start apply.
|
|
197
202
|
@toolkit_binding = build_toolkit_binding
|
|
203
|
+
# The notices a turn's thinking level leads to (once per session and host).
|
|
204
|
+
@thinking_notices = ThinkingNotices.new(session: -> { @session }, profile: -> { self.profile },
|
|
205
|
+
notify: ->(text, level, source) { hook_notify(text, level, source) })
|
|
198
206
|
# The tools plugins publish without declaring (chi.tool_source), and
|
|
199
207
|
# the search and call tools that reach them (Tools::ToolIndex).
|
|
200
208
|
@tool_index = Tools::ToolIndex.new
|
|
@@ -204,6 +212,10 @@ module Samagotchi
|
|
|
204
212
|
@lifecycle_mutex = Mutex.new
|
|
205
213
|
@shut_down = false
|
|
206
214
|
load_plugins if plugins
|
|
215
|
+
# Registered from what the sources published while they loaded, and
|
|
216
|
+
# with no plugins too: a toolkit's left-out built-ins are reachable
|
|
217
|
+
# either way (declared per toolkit, Tools::Registry).
|
|
218
|
+
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
207
219
|
guardrail_rules
|
|
208
220
|
# The Engine owns the reminder store; its kernel (built here, or a
|
|
209
221
|
# spec's) gets it, so tool calls via KernelLoop and reminder injection
|
|
@@ -288,7 +300,7 @@ module Samagotchi
|
|
|
288
300
|
def wire_kernel(kernel, muted_memories)
|
|
289
301
|
# A given kernel (specs) gets the Engine's store, hooks and tools, as
|
|
290
302
|
# the one built here does.
|
|
291
|
-
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name:
|
|
303
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: effective_model_name,
|
|
292
304
|
hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
|
|
293
305
|
@kernel.reminder_store = @reminder_store
|
|
294
306
|
@kernel.hooks = @hooks
|
|
@@ -373,8 +385,8 @@ module Samagotchi
|
|
|
373
385
|
end
|
|
374
386
|
|
|
375
387
|
# The backend for the next turn: the loop the effective model's host speaks.
|
|
376
|
-
def backend
|
|
377
|
-
backend_for(
|
|
388
|
+
def backend(model = effective_model)
|
|
389
|
+
backend_for(model)
|
|
378
390
|
end
|
|
379
391
|
|
|
380
392
|
# Record that activity happened (user input or a completed turn): the
|
|
@@ -522,14 +534,17 @@ module Samagotchi
|
|
|
522
534
|
|
|
523
535
|
# @return [SessionMetrics] the per-session analytics collector
|
|
524
536
|
attr_reader :metrics
|
|
525
|
-
|
|
526
|
-
#
|
|
527
|
-
|
|
537
|
+
|
|
538
|
+
# The model as given (maybe an alias; HostRegistry#resolve applies it).
|
|
539
|
+
def effective_model_name = @model_choice.name
|
|
540
|
+
|
|
541
|
+
# The model as typed at start or at the last /model.
|
|
542
|
+
def typed_model_name = @model_choice.typed
|
|
528
543
|
|
|
529
544
|
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
530
545
|
# the alias applied, "host:id" when it names a host).
|
|
531
546
|
def effective_model_ref
|
|
532
|
-
model_ref_for(
|
|
547
|
+
model_ref_for(effective_model_name)
|
|
533
548
|
end
|
|
534
549
|
|
|
535
550
|
# The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
|
|
@@ -537,11 +552,11 @@ module Samagotchi
|
|
|
537
552
|
# only, the model its /props names when that differs. Never a new
|
|
538
553
|
# request: only the /props answer profile resolution already cached.
|
|
539
554
|
def model_identity
|
|
540
|
-
|
|
541
|
-
entry =
|
|
555
|
+
model = effective_model
|
|
556
|
+
entry = model.entry
|
|
542
557
|
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
543
|
-
SystemPrompt::ModelIdentity.new(ref:
|
|
544
|
-
served:
|
|
558
|
+
SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
|
|
559
|
+
served: model.prompt_served, fallback_key: @fallback_model_key)
|
|
545
560
|
end
|
|
546
561
|
|
|
547
562
|
# The model notes for the effective model (ModelNotes): matched on its
|
|
@@ -586,10 +601,10 @@ module Samagotchi
|
|
|
586
601
|
summary = error.respond_to?(:summary) ? error.summary : error.message
|
|
587
602
|
return summary unless error.is_a?(LLM::ProviderError) && error.host
|
|
588
603
|
|
|
589
|
-
note = @host_registry.alias_note(
|
|
604
|
+
note = @host_registry.alias_note(effective_model_name)
|
|
590
605
|
note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
|
|
591
|
-
rescue StandardError
|
|
592
|
-
summary
|
|
606
|
+
rescue StandardError => e
|
|
607
|
+
EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
|
|
593
608
|
end
|
|
594
609
|
|
|
595
610
|
# +name+'s resolved ref (ModelRef#ref).
|
|
@@ -603,7 +618,8 @@ module Samagotchi
|
|
|
603
618
|
def store_model!(session)
|
|
604
619
|
ref = effective_model_ref
|
|
605
620
|
session.model_name = ref
|
|
606
|
-
|
|
621
|
+
typed = typed_model_name
|
|
622
|
+
session.model_typed = typed.to_s == ref ? nil : typed
|
|
607
623
|
session
|
|
608
624
|
end
|
|
609
625
|
|
|
@@ -622,11 +638,17 @@ module Samagotchi
|
|
|
622
638
|
# and kept, so the system prompt and the server's KV prefix stay stable;
|
|
623
639
|
# #switch_model! starts over, and a failed server probe is retried before
|
|
624
640
|
# the next turn (#refresh_profile!). The kernel follows each resolution.
|
|
641
|
+
# Kept with the model choice it was resolved for: one that finishes
|
|
642
|
+
# after a switch is neither kept nor applied (#store_profile).
|
|
625
643
|
# @return [ModelProfile::Resolution]
|
|
626
644
|
def profile_resolution
|
|
627
|
-
|
|
645
|
+
choice = @model_choice
|
|
646
|
+
cached = @profile_resolution
|
|
647
|
+
return cached.last if cached&.first.equal?(choice)
|
|
648
|
+
|
|
649
|
+
store_profile(choice, resolve_profile(choice))
|
|
628
650
|
end
|
|
629
|
-
attr_reader :host_registry, :client
|
|
651
|
+
attr_reader :default_model_name, :host_registry, :client
|
|
630
652
|
|
|
631
653
|
# @return [Commands::Registry] the commands this session runs: the
|
|
632
654
|
# built-ins, and the ones bundle plugins add
|
|
@@ -645,8 +667,9 @@ module Samagotchi
|
|
|
645
667
|
# (before aliases were applied there): that key is read when the
|
|
646
668
|
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
647
669
|
def sync_model_key!
|
|
648
|
-
|
|
649
|
-
|
|
670
|
+
choice = @model_choice
|
|
671
|
+
@model_key = ModelOverlay.key_for(bare_model_name(choice.name))
|
|
672
|
+
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
|
|
650
673
|
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
651
674
|
@kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
652
675
|
end
|
|
@@ -655,17 +678,17 @@ module Samagotchi
|
|
|
655
678
|
# The effective model's bare name for the guardrails' models: rules;
|
|
656
679
|
# nil without a model name.
|
|
657
680
|
def guardrail_model_name
|
|
658
|
-
|
|
681
|
+
model = effective_model_name
|
|
682
|
+
name = model && bare_model_name(model)
|
|
659
683
|
name.to_s.strip.empty? ? nil : name
|
|
660
684
|
end
|
|
661
685
|
|
|
662
686
|
# Point the kernel (and a chat backend) at the effective model's host
|
|
663
687
|
# (after /model, --model, resume).
|
|
664
|
-
def sync_kernel_client!
|
|
665
|
-
|
|
666
|
-
@client =
|
|
667
|
-
|
|
668
|
-
backend_for(target)
|
|
688
|
+
def sync_kernel_client!(model = effective_model)
|
|
689
|
+
@client = model.client
|
|
690
|
+
@kernel.client = model.client if @kernel.client != model.client
|
|
691
|
+
backend_for(model)
|
|
669
692
|
end
|
|
670
693
|
|
|
671
694
|
# Subscribe a persistent observer to engine events.
|
|
@@ -966,11 +989,14 @@ module Samagotchi
|
|
|
966
989
|
# The alias is applied where the model is resolved (HostRegistry#resolve),
|
|
967
990
|
# once; checked here: an unknown host, an alias naming another host.
|
|
968
991
|
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
969
|
-
|
|
970
|
-
@
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
992
|
+
choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
|
|
993
|
+
@profile_mutex.synchronize do
|
|
994
|
+
# One assignment: a reader on another thread sees the old pair or the new one.
|
|
995
|
+
@model_choice = choice
|
|
996
|
+
# A profile given to .new was for the starting model.
|
|
997
|
+
@given_profile = nil
|
|
998
|
+
@profile_resolution = nil
|
|
999
|
+
end
|
|
974
1000
|
sync_model_key!
|
|
975
1001
|
@prompt_builder.reset!
|
|
976
1002
|
# The notes the new model's prompt loads at its next build, so /model,
|
|
@@ -1041,22 +1067,24 @@ module Samagotchi
|
|
|
1041
1067
|
# @return [Hash] @metrics.snapshot, filled in
|
|
1042
1068
|
def stats_snapshot
|
|
1043
1069
|
snapshot = @metrics.snapshot
|
|
1044
|
-
|
|
1045
|
-
|
|
1070
|
+
# One resolve for the whole snapshot (its readers' rescues stay theirs);
|
|
1071
|
+
# a model that doesn't resolve raises here, as it always has.
|
|
1072
|
+
model = effective_model
|
|
1073
|
+
served, served_for = served_model_for(snapshot, model: model)
|
|
1046
1074
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1047
|
-
served_expected_by: served_expected_by(served
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1075
|
+
served_expected_by: model.served_expected_by(served),
|
|
1076
|
+
**setting_chips(SessionSetting::STATS, model),
|
|
1077
|
+
prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
|
|
1078
|
+
price_host: model.host_name)
|
|
1051
1079
|
unless snapshot.dig(:context, :window_tokens)
|
|
1052
|
-
window = current_context_window(
|
|
1080
|
+
window = current_context_window(model)
|
|
1053
1081
|
if window
|
|
1054
1082
|
snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
|
|
1055
1083
|
window_source: window.source.to_s))
|
|
1056
1084
|
end
|
|
1057
1085
|
end
|
|
1058
1086
|
# The chat loop uses no prompt profile: drop one a native turn reported.
|
|
1059
|
-
return snapshot.except(:profile, :profile_source) if
|
|
1087
|
+
return snapshot.except(:profile, :profile_source) if model.chat?
|
|
1060
1088
|
|
|
1061
1089
|
unless snapshot[:profile]
|
|
1062
1090
|
resolution = profile_resolution
|
|
@@ -1069,10 +1097,11 @@ module Samagotchi
|
|
|
1069
1097
|
# no turn yet (it woke after an idle exit): nil when either count is
|
|
1070
1098
|
# unknown or context.status is off. Counted as the turns count it:
|
|
1071
1099
|
# against the effective model's llm_context budget when it has one.
|
|
1072
|
-
def saved_context_status(context)
|
|
1100
|
+
def saved_context_status(context, model: :current)
|
|
1073
1101
|
return nil unless context
|
|
1074
1102
|
|
|
1075
|
-
|
|
1103
|
+
llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
|
|
1104
|
+
ContextStatus.new(llm_context: llm_context)
|
|
1076
1105
|
.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
|
|
1077
1106
|
end
|
|
1078
1107
|
|
|
@@ -1080,10 +1109,7 @@ module Samagotchi
|
|
|
1080
1109
|
# (LLMContextStrategy.explain), nil when it can't be resolved.
|
|
1081
1110
|
# @return [LLMContextStrategy::Explained, nil]
|
|
1082
1111
|
def llm_context_explained
|
|
1083
|
-
|
|
1084
|
-
LLMContextStrategy.explain(target, names: model_lookup_names(target), **llm_context_session_args)
|
|
1085
|
-
rescue StandardError
|
|
1086
|
-
nil
|
|
1112
|
+
model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
|
|
1087
1113
|
end
|
|
1088
1114
|
|
|
1089
1115
|
# #llm_context_explained's summary (Explained#summary), nil when it
|
|
@@ -1095,7 +1121,7 @@ module Samagotchi
|
|
|
1095
1121
|
|
|
1096
1122
|
# The session's own llm_context values, nil when it has none.
|
|
1097
1123
|
# @return [LLMContextOverride, nil]
|
|
1098
|
-
def llm_context_override = @session
|
|
1124
|
+
def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session)
|
|
1099
1125
|
|
|
1100
1126
|
# Set the session's own llm_context values (an empty override unsets
|
|
1101
1127
|
# them); the next turn's start resolves them, never a running one (its
|
|
@@ -1104,23 +1130,18 @@ module Samagotchi
|
|
|
1104
1130
|
def llm_context_override=(override)
|
|
1105
1131
|
raise ArgumentError, "no session yet" unless @session
|
|
1106
1132
|
|
|
1107
|
-
@session
|
|
1133
|
+
SessionSetting::LLM_CONTEXT.set(@session, override)
|
|
1108
1134
|
# The status a later client reads counts against the new budget.
|
|
1109
1135
|
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
1110
1136
|
end
|
|
1111
1137
|
|
|
1112
1138
|
# The effective model is on a chat host (api: openai or anthropic), whose loop uses
|
|
1113
1139
|
# no prompt profile.
|
|
1114
|
-
def chat_model? =
|
|
1140
|
+
def chat_model? = effective_model.chat?
|
|
1115
1141
|
|
|
1116
1142
|
# "temperature=0.6 (hosts.work)" for /model: the effective model's
|
|
1117
1143
|
# configured sampling, nil when none.
|
|
1118
|
-
def sampling_summary
|
|
1119
|
-
target = @host_registry.resolve(@effective_model_name)
|
|
1120
|
-
SamplingSettings.summary(target, names: model_lookup_names(target))
|
|
1121
|
-
rescue StandardError
|
|
1122
|
-
nil
|
|
1123
|
-
end
|
|
1144
|
+
def sampling_summary = model_setting(nil, &:sampling_summary)
|
|
1124
1145
|
|
|
1125
1146
|
# "off (models: qwen)", "low (session)" for /model: the effective
|
|
1126
1147
|
# model's thinking level and where it came from, nil when none is set.
|
|
@@ -1134,10 +1155,7 @@ module Samagotchi
|
|
|
1134
1155
|
# turn_sampling; on any error, the global and its name.
|
|
1135
1156
|
# @return [Array(Integer, String|nil)]
|
|
1136
1157
|
def steer_cut_after_explained
|
|
1137
|
-
|
|
1138
|
-
SteerCutAfter.resolve(target, names: model_lookup_names(target))
|
|
1139
|
-
rescue StandardError
|
|
1140
|
-
[SteerCut.cut_after, "steer.cut_after"]
|
|
1158
|
+
model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
|
|
1141
1159
|
end
|
|
1142
1160
|
|
|
1143
1161
|
# #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
|
|
@@ -1159,10 +1177,7 @@ module Samagotchi
|
|
|
1159
1177
|
# +own+: the session's own level to resolve with (the current one by
|
|
1160
1178
|
# default; /thinking asks what a change would resolve to).
|
|
1161
1179
|
def thinking_explained(own: thinking_override)
|
|
1162
|
-
|
|
1163
|
-
Thinking.explain(target, names: model_lookup_names(target), session: own)
|
|
1164
|
-
rescue StandardError
|
|
1165
|
-
nil
|
|
1180
|
+
model_setting(nil) { |model| model.thinking_explained(own: own) }
|
|
1166
1181
|
end
|
|
1167
1182
|
|
|
1168
1183
|
# "drop (models: qwen)" for /model: the effective chat model's
|
|
@@ -1170,14 +1185,7 @@ module Samagotchi
|
|
|
1170
1185
|
# default, the model runs on the native loop (which ignores it) or it
|
|
1171
1186
|
# can't be resolved.
|
|
1172
1187
|
def reasoning_history_summary
|
|
1173
|
-
|
|
1174
|
-
return nil unless target.entry&.chat?
|
|
1175
|
-
|
|
1176
|
-
level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
|
|
1177
|
-
session: reasoning_history_override)
|
|
1178
|
-
source ? ReasoningHistory.label(level, source) : nil
|
|
1179
|
-
rescue StandardError
|
|
1180
|
-
nil
|
|
1188
|
+
model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
|
|
1181
1189
|
end
|
|
1182
1190
|
|
|
1183
1191
|
# The effective chat model's reasoning_history level with where it came
|
|
@@ -1189,12 +1197,7 @@ module Samagotchi
|
|
|
1189
1197
|
# +own+: the session's own level to resolve with (the current one by
|
|
1190
1198
|
# default; a command asks what a change would resolve to).
|
|
1191
1199
|
def reasoning_history_explained(native: false, own: reasoning_history_override)
|
|
1192
|
-
|
|
1193
|
-
return nil unless native || target.entry&.chat?
|
|
1194
|
-
|
|
1195
|
-
ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
|
|
1196
|
-
rescue StandardError
|
|
1197
|
-
nil
|
|
1200
|
+
model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
|
|
1198
1201
|
end
|
|
1199
1202
|
|
|
1200
1203
|
# What a reasoning_history change from level +from+ to level +to+ costs
|
|
@@ -1203,10 +1206,7 @@ module Samagotchi
|
|
|
1203
1206
|
# marked as refusing it may still get :history (one needless ask).
|
|
1204
1207
|
# @return [Symbol, nil]
|
|
1205
1208
|
def reasoning_history_cache_cost(from, to)
|
|
1206
|
-
|
|
1207
|
-
ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
|
|
1208
|
-
rescue StandardError
|
|
1209
|
-
nil
|
|
1209
|
+
model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session&.messages) }
|
|
1210
1210
|
end
|
|
1211
1211
|
|
|
1212
1212
|
# Whether the session has a model reply (a cache to restart at all).
|
|
@@ -1216,7 +1216,7 @@ module Samagotchi
|
|
|
1216
1216
|
|
|
1217
1217
|
# The session's own reasoning_history level, nil when it has none.
|
|
1218
1218
|
# @return [Symbol, nil]
|
|
1219
|
-
def reasoning_history_override = @session
|
|
1219
|
+
def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session)
|
|
1220
1220
|
|
|
1221
1221
|
# Set the session's own reasoning_history level (nil unsets it); the
|
|
1222
1222
|
# next turn's start resolves it. The caller saves the session.
|
|
@@ -1224,19 +1224,18 @@ module Samagotchi
|
|
|
1224
1224
|
def reasoning_history_override=(level)
|
|
1225
1225
|
raise ArgumentError, "no session yet" unless @session
|
|
1226
1226
|
|
|
1227
|
-
@session
|
|
1227
|
+
SessionSetting::REASONING_HISTORY.set(@session, level)
|
|
1228
1228
|
end
|
|
1229
1229
|
|
|
1230
1230
|
# The session's own thinking level, nil when it has none.
|
|
1231
1231
|
# @return [Symbol, nil]
|
|
1232
|
-
def thinking_override = @session
|
|
1232
|
+
def thinking_override = SessionSetting::THINKING.own(@session)
|
|
1233
1233
|
|
|
1234
1234
|
# What a thinking level change costs the effective model's prompt
|
|
1235
1235
|
# cache (Thinking.cache_cost): :full, :provider or :tail.
|
|
1236
1236
|
# @return [Symbol]
|
|
1237
1237
|
def thinking_cache_cost
|
|
1238
|
-
|
|
1239
|
-
Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
|
|
1238
|
+
effective_model.thinking_cache_cost { profile }
|
|
1240
1239
|
end
|
|
1241
1240
|
|
|
1242
1241
|
# Set the session's own thinking level (nil unsets it); the next turn's
|
|
@@ -1246,7 +1245,7 @@ module Samagotchi
|
|
|
1246
1245
|
def thinking_override=(level)
|
|
1247
1246
|
raise ArgumentError, "no session yet" unless @session
|
|
1248
1247
|
|
|
1249
|
-
@session
|
|
1248
|
+
SessionSetting::THINKING.set(@session, level)
|
|
1250
1249
|
end
|
|
1251
1250
|
|
|
1252
1251
|
# The model the server serves for the current model, and the name asked
|
|
@@ -1255,17 +1254,15 @@ module Samagotchi
|
|
|
1255
1254
|
# else [nil, nil].
|
|
1256
1255
|
# @return [Array(String, String), Array(nil, nil)]
|
|
1257
1256
|
def served_model(probe: true)
|
|
1258
|
-
served_model_for(@metrics.served_report,
|
|
1257
|
+
served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
|
|
1259
1258
|
end
|
|
1260
1259
|
|
|
1261
1260
|
# The host whose hosts.<name>.models.<id>.served names +served+ for the
|
|
1262
1261
|
# current model (a gateway's round-robin targets): not a mismatch to
|
|
1263
1262
|
# warn about. nil when none does.
|
|
1264
1263
|
# @return [String, nil]
|
|
1265
|
-
def served_expected_by(served
|
|
1266
|
-
|
|
1267
|
-
rescue StandardError
|
|
1268
|
-
nil
|
|
1264
|
+
def served_expected_by(served)
|
|
1265
|
+
model_setting(nil) { |model| model.served_expected_by(served) }
|
|
1269
1266
|
end
|
|
1270
1267
|
|
|
1271
1268
|
def served_expected?(served) = !served_expected_by(served).nil?
|
|
@@ -1297,6 +1294,10 @@ module Samagotchi
|
|
|
1297
1294
|
def session_state_snapshot
|
|
1298
1295
|
served_pair = served_model(probe: false)
|
|
1299
1296
|
metrics = @metrics.snapshot
|
|
1297
|
+
# One resolve for the model's fields; nil when it doesn't resolve (the
|
|
1298
|
+
# Bridge's /state and reset frames rely on this never raising): those
|
|
1299
|
+
# fields are nil then, as their own rescues always gave.
|
|
1300
|
+
model = model_setting(nil) { |resolved| resolved }
|
|
1300
1301
|
{
|
|
1301
1302
|
status: @session&.status,
|
|
1302
1303
|
message_count: (@session&.messages || []).size,
|
|
@@ -1312,12 +1313,9 @@ module Samagotchi
|
|
|
1312
1313
|
model_name: effective_model_ref,
|
|
1313
1314
|
served_model: served_pair[0],
|
|
1314
1315
|
served_model_for: served_pair[1],
|
|
1315
|
-
served_expected_by: served_expected_by(served_pair[0]),
|
|
1316
|
-
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1317
|
-
|
|
1318
|
-
thinking: thinking_explained&.summary,
|
|
1319
|
-
reasoning_history: reasoning_history_explained&.summary,
|
|
1320
|
-
toolkit: toolkit_summary,
|
|
1316
|
+
served_expected_by: model&.served_expected_by(served_pair[0]),
|
|
1317
|
+
context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
|
|
1318
|
+
**setting_chips(SessionSetting::ALL, model),
|
|
1321
1319
|
recap_enabled: !@recap.nil?,
|
|
1322
1320
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1323
1321
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1673,15 +1671,17 @@ module Samagotchi
|
|
|
1673
1671
|
# the chat loop's leaves out the raw-prompt tool text, since its tools go
|
|
1674
1672
|
# as schemas. Built once per loop (and again after a model switch) so the
|
|
1675
1673
|
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
1676
|
-
# @param
|
|
1674
|
+
# @param model [EffectiveModel, nil]
|
|
1677
1675
|
# @return [String]
|
|
1678
1676
|
# The native prompt depends on the thinking level too (Gemma's token,
|
|
1679
|
-
# Qwen's turn preamble): a level change builds it again.
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1677
|
+
# Qwen's turn preamble): a level change builds it again. A build that
|
|
1678
|
+
# misses the cache names the model and the level through its own
|
|
1679
|
+
# lambdas (#model_identity, #turn_thinking), which resolve again.
|
|
1680
|
+
def system_prompt(model = nil)
|
|
1681
|
+
model ||= effective_model
|
|
1682
|
+
chat = model.chat?
|
|
1683
|
+
level = chat ? nil : thinking_level(model)
|
|
1684
|
+
prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(llm_context_session_args))
|
|
1685
1685
|
# The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
|
|
1686
1686
|
@metrics.memory_index = @prompt_builder.memory_index
|
|
1687
1687
|
# The model notes it carries: the session records them (/stats, /model, the web).
|
|
@@ -1689,16 +1689,6 @@ module Samagotchi
|
|
|
1689
1689
|
prompt
|
|
1690
1690
|
end
|
|
1691
1691
|
|
|
1692
|
-
# The LLM context layers +target+'s turns run under: the native prompt
|
|
1693
|
-
# declares the tools they add (forget_outputs under forget), so a
|
|
1694
|
-
# model switch may change the tool list. [] under none.
|
|
1695
|
-
def llm_context_layers(target)
|
|
1696
|
-
LLMContextStrategy.resolve(target, names: model_lookup_names(target), **llm_context_session_args).active_layers
|
|
1697
|
-
rescue StandardError
|
|
1698
|
-
[]
|
|
1699
|
-
end
|
|
1700
|
-
private :llm_context_layers
|
|
1701
|
-
|
|
1702
1692
|
# The base prompt (specs, plugins' declarations).
|
|
1703
1693
|
def assist_system_prompt(chat: false, thinking: nil)
|
|
1704
1694
|
@prompt_builder.base(chat: chat, thinking: thinking)
|
|
@@ -2029,14 +2019,16 @@ module Samagotchi
|
|
|
2029
2019
|
# sampling and thinking, the thinking resolved before the hooks run.
|
|
2030
2020
|
# The model name is added in #generate, after the hooks that may switch
|
|
2031
2021
|
# the model.
|
|
2022
|
+
# One resolve for all of them: nothing runs between them that could
|
|
2023
|
+
# change what a resolve gives.
|
|
2032
2024
|
def turn_settings(session)
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking:
|
|
2039
|
-
reasoning_history: reasoning_history_level(
|
|
2025
|
+
model = effective_model
|
|
2026
|
+
vision = turn_vision(session, model)
|
|
2027
|
+
sampling = model.sampling
|
|
2028
|
+
level = thinking_level(model)
|
|
2029
|
+
@thinking_notices.turn_started(level, model.target)
|
|
2030
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
|
|
2031
|
+
reasoning_history: reasoning_history_level(model), model_name: nil,
|
|
2040
2032
|
window_setting: nil, llm_context: nil, price: nil)
|
|
2041
2033
|
end
|
|
2042
2034
|
private :turn_settings
|
|
@@ -2058,12 +2050,12 @@ module Samagotchi
|
|
|
2058
2050
|
def warm_up_next_turn(turn, result)
|
|
2059
2051
|
return if result.canceled? || result.resumable?
|
|
2060
2052
|
|
|
2061
|
-
|
|
2062
|
-
client =
|
|
2063
|
-
return unless warmup_target?(
|
|
2053
|
+
model = effective_model
|
|
2054
|
+
client = model.client
|
|
2055
|
+
return unless warmup_target?(model, client)
|
|
2064
2056
|
return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
|
|
2065
2057
|
|
|
2066
|
-
messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(
|
|
2058
|
+
messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
|
|
2067
2059
|
# Under the next turn's strategy (a /model switch may have changed it);
|
|
2068
2060
|
# the last turn's when it can't be resolved.
|
|
2069
2061
|
prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
|
|
@@ -2080,8 +2072,8 @@ module Samagotchi
|
|
|
2080
2072
|
# Never a paid or remote host (cache.warmup auto means local only), nor
|
|
2081
2073
|
# the chat loop (Splash keeps its own cache; llama.cpp's chat path is
|
|
2082
2074
|
# not measured), nor a non-llama.cpp transport.
|
|
2083
|
-
def warmup_target?(
|
|
2084
|
-
return false if
|
|
2075
|
+
def warmup_target?(model, client)
|
|
2076
|
+
return false if model.chat? || model.entry.remote?
|
|
2085
2077
|
return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
|
|
2086
2078
|
|
|
2087
2079
|
PromptWarmup.enabled?
|
|
@@ -2122,16 +2114,19 @@ module Samagotchi
|
|
|
2122
2114
|
|
|
2123
2115
|
# Ask the effective model's backend.
|
|
2124
2116
|
def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
|
|
2125
|
-
|
|
2117
|
+
# One resolve for the request: nothing runs between these reads that
|
|
2118
|
+
# could change what a resolve gives (the hooks ran before).
|
|
2119
|
+
model = effective_model
|
|
2120
|
+
sync_kernel_client!(model)
|
|
2126
2121
|
# Route model name as bare (without host prefix) to the transport;
|
|
2127
2122
|
# host selection already done via active client.
|
|
2128
|
-
bare_for_backend = bare_model_name(
|
|
2123
|
+
bare_for_backend = bare_model_name(model.name)
|
|
2129
2124
|
# The turn's settings, set once here. The chat loop dispatches tools
|
|
2130
2125
|
# through the kernel without its #run: tag those dumps with this
|
|
2131
2126
|
# turn's model, not the last native one.
|
|
2132
|
-
explained = llm_context_explained
|
|
2133
|
-
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting:
|
|
2134
|
-
llm_context: explained&.resolved, price:
|
|
2127
|
+
explained = model.llm_context_explained(llm_context_session_args)
|
|
2128
|
+
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
|
|
2129
|
+
llm_context: explained&.resolved, price: model.price)
|
|
2135
2130
|
log_llm_context(explained)
|
|
2136
2131
|
|
|
2137
2132
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
@@ -2139,7 +2134,7 @@ module Samagotchi
|
|
|
2139
2134
|
# end keeps it, so a Continue resumes this run.
|
|
2140
2135
|
run_start = turn.messages.length
|
|
2141
2136
|
reasoning_since = resumed_run_start(turn, run_start)
|
|
2142
|
-
result = backend.complete(
|
|
2137
|
+
result = backend(model).complete(
|
|
2143
2138
|
messages: turn.messages,
|
|
2144
2139
|
max_iterations: turn.limit,
|
|
2145
2140
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
@@ -2353,164 +2348,30 @@ module Samagotchi
|
|
|
2353
2348
|
|
|
2354
2349
|
# The turn's VisionContext: the session's images folder, and whether the
|
|
2355
2350
|
# effective model can see images, asked only when a request carries one.
|
|
2356
|
-
def turn_vision(session)
|
|
2357
|
-
target = @host_registry.resolve(@effective_model_name)
|
|
2351
|
+
def turn_vision(session, model = effective_model)
|
|
2358
2352
|
VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
|
|
2359
|
-
capability:
|
|
2360
|
-
VisionSupport.for(target, profile: profile, adapter: vision_adapter(target), names: model_lookup_names(target))
|
|
2361
|
-
})
|
|
2353
|
+
capability: -> { model.vision_support(profile) })
|
|
2362
2354
|
end
|
|
2363
2355
|
|
|
2364
2356
|
# The effective model's request parameters (hosts: and models:
|
|
2365
2357
|
# sampling), read per turn: /model can change the model between turns.
|
|
2366
|
-
def turn_sampling
|
|
2367
|
-
target = @host_registry.resolve(@effective_model_name)
|
|
2368
|
-
SamplingSettings.for(target, names: model_lookup_names(target))
|
|
2369
|
-
rescue StandardError
|
|
2370
|
-
SamplingSettings::EMPTY
|
|
2371
|
-
end
|
|
2358
|
+
def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
|
|
2372
2359
|
|
|
2373
2360
|
# The effective model's thinking level (Thinking.resolve), read each turn.
|
|
2374
|
-
def turn_thinking
|
|
2375
|
-
thinking_level(@host_registry.resolve(@effective_model_name))
|
|
2376
|
-
rescue StandardError
|
|
2377
|
-
Thinking::DEFAULT
|
|
2378
|
-
end
|
|
2379
|
-
|
|
2380
|
-
# An effort on a native host, which has no knob for it: said once per
|
|
2381
|
-
# session and host.
|
|
2382
|
-
def announce_thinking_level(level, target)
|
|
2383
|
-
return if target.entry.chat? || Thinking.native(level, profile).honoured
|
|
2384
|
-
|
|
2385
|
-
thinking_notice_once(:unsupported, target, :info,
|
|
2386
|
-
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
2387
|
-
"thinking stays as the model has it")
|
|
2388
|
-
rescue StandardError
|
|
2389
|
-
nil
|
|
2390
|
-
end
|
|
2391
|
-
|
|
2392
|
-
# An effort on a llama.cpp chat host whose chat template takes none
|
|
2393
|
-
# (its /props says so): said once per session and host, as for a native
|
|
2394
|
-
# host. Read from the /props answer the turn's window probe left in the
|
|
2395
|
-
# cache, so it asks the server nothing.
|
|
2396
|
-
def announce_effort_ignored
|
|
2397
|
-
level, target = @turn_thinking
|
|
2398
|
-
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
2399
|
-
|
|
2400
|
-
client = target.client
|
|
2401
|
-
props = client.cached_server_props(model: target.bare_model)
|
|
2402
|
-
return unless Thinking.effort_ignored?(level, props)
|
|
2403
|
-
|
|
2404
|
-
thinking_notice_once(:unsupported, target, :info,
|
|
2405
|
-
"#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
|
|
2406
|
-
"(/props: supports_reasoning_effort false); thinking stays as the model has it")
|
|
2407
|
-
rescue StandardError
|
|
2408
|
-
nil
|
|
2409
|
-
end
|
|
2410
|
-
|
|
2411
|
-
# Whether this generation's thinking lane said anything: nil while it
|
|
2412
|
-
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
2413
|
-
# with thinking off), true once a real thought came.
|
|
2414
|
-
def note_generation_thought(thinking)
|
|
2415
|
-
return if thinking.empty?
|
|
2416
|
-
|
|
2417
|
-
@generation_thought ||= thinking.match?(/\S/)
|
|
2418
|
-
end
|
|
2419
|
-
|
|
2420
|
-
# Thinking off, and the model thought anyway: logged each time, said
|
|
2421
|
-
# once per session and host. A thinking lane of whitespace alone is no
|
|
2422
|
-
# thought.
|
|
2423
|
-
def check_thinking_honoured(event)
|
|
2424
|
-
level, target = @turn_thinking
|
|
2425
|
-
chars = event[:thinking_chars].to_i
|
|
2426
|
-
return unless level == :off && chars.positive? && target
|
|
2427
|
-
return if @generation_thought == false
|
|
2428
|
-
|
|
2429
|
-
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
2430
|
-
thinking_notice_once(:not_honoured, target, :warn,
|
|
2431
|
-
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
2432
|
-
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
2433
|
-
"(see Thinking in docs/configuration.md)")
|
|
2434
|
-
rescue StandardError
|
|
2435
|
-
nil
|
|
2436
|
-
end
|
|
2361
|
+
def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
|
|
2437
2362
|
|
|
2438
|
-
#
|
|
2439
|
-
# thinking off) and the chat loop sent the request without them: said
|
|
2440
|
-
# once per session and host, standing in for the not-honoured notice.
|
|
2441
|
-
def thinking_refused(event)
|
|
2442
|
-
_level, target = @turn_thinking
|
|
2443
|
-
return unless target
|
|
2444
|
-
|
|
2445
|
-
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
2446
|
-
detail: event[:detail])
|
|
2447
|
-
(@thinking_notices ||= Set.new) << [@session&.id, target.entry.name, :not_honoured]
|
|
2448
|
-
thinking_notice_once(:refused, target, :warn,
|
|
2449
|
-
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
2450
|
-
"sent without it, so thinking stays as the model has it")
|
|
2451
|
-
rescue StandardError
|
|
2452
|
-
nil
|
|
2453
|
-
end
|
|
2454
|
-
|
|
2455
|
-
# The host refused a sampling field (the empty-answer retry's
|
|
2456
|
-
# temperature on a model that takes none) and the chat loop asked again
|
|
2457
|
-
# without them, as it will for the model from now on: a log line.
|
|
2458
|
-
def sampling_refused(event)
|
|
2459
|
-
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
2460
|
-
rescue StandardError
|
|
2461
|
-
nil
|
|
2462
|
-
end
|
|
2463
|
-
|
|
2464
|
-
# The host refused the model's reasoning sent back (reasoning_history)
|
|
2465
|
-
# and the chat loop asked the step again without it, as it will from
|
|
2466
|
-
# now on: logged, and said once per session and host.
|
|
2467
|
-
def reasoning_history_refused(event)
|
|
2468
|
-
_level, target = @turn_thinking
|
|
2469
|
-
return unless target
|
|
2470
|
-
|
|
2471
|
-
Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
2472
|
-
detail: event[:detail])
|
|
2473
|
-
return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
|
|
2474
|
-
|
|
2475
|
-
hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
|
|
2476
|
-
"#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
|
|
2477
|
-
rescue StandardError
|
|
2478
|
-
nil
|
|
2479
|
-
end
|
|
2480
|
-
|
|
2481
|
-
def thinking_notice_once(kind, target, level, text)
|
|
2482
|
-
key = [@session&.id, target.entry.name, kind]
|
|
2483
|
-
return unless (@thinking_notices ||= Set.new).add?(key)
|
|
2484
|
-
|
|
2485
|
-
hook_notify(text, level, "thinking")
|
|
2486
|
-
end
|
|
2487
|
-
|
|
2488
|
-
# +target+'s ReasoningHistory level, the session's own first; the
|
|
2363
|
+
# +model+'s ReasoningHistory level, the session's own first; the
|
|
2489
2364
|
# default when it can't be resolved.
|
|
2490
|
-
def reasoning_history_level(
|
|
2491
|
-
ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
|
|
2492
|
-
rescue StandardError
|
|
2493
|
-
ReasoningHistory::DEFAULT
|
|
2494
|
-
end
|
|
2495
|
-
|
|
2496
|
-
# +target+'s thinking level (Thinking.resolve), the session's own first.
|
|
2497
|
-
def thinking_level(target)
|
|
2498
|
-
Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
|
|
2499
|
-
rescue StandardError
|
|
2500
|
-
Thinking::DEFAULT
|
|
2501
|
-
end
|
|
2365
|
+
def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
|
|
2502
2366
|
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
rescue StandardError
|
|
2506
|
-
nil
|
|
2507
|
-
end
|
|
2367
|
+
# +model+'s thinking level (Thinking.resolve), the session's own first.
|
|
2368
|
+
def thinking_level(model) = model.thinking(own: thinking_override)
|
|
2508
2369
|
|
|
2509
2370
|
# A model known not to see images fails a turn with images up front.
|
|
2510
2371
|
def refuse_images!(vision)
|
|
2511
2372
|
return if vision.sendable?
|
|
2512
2373
|
|
|
2513
|
-
host =
|
|
2374
|
+
host = effective_model.host_name
|
|
2514
2375
|
raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
|
|
2515
2376
|
end
|
|
2516
2377
|
|
|
@@ -2605,14 +2466,14 @@ module Samagotchi
|
|
|
2605
2466
|
session.messages = messages
|
|
2606
2467
|
end
|
|
2607
2468
|
|
|
2608
|
-
# Pick the loop for
|
|
2609
|
-
# host's adapter (one per host, kept for its cached model list).
|
|
2610
|
-
def backend_for(
|
|
2611
|
-
return @native_backend unless
|
|
2469
|
+
# Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
|
|
2470
|
+
# at its host's adapter (one per host, kept for its cached model list).
|
|
2471
|
+
def backend_for(model)
|
|
2472
|
+
return @native_backend unless model.chat?
|
|
2612
2473
|
|
|
2613
2474
|
@chat_backend_mutex.synchronize do
|
|
2614
2475
|
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
|
|
2615
|
-
@chat_backend.adapter = @host_registry.adapter_for(
|
|
2476
|
+
@chat_backend.adapter = @host_registry.adapter_for(model.entry)
|
|
2616
2477
|
@chat_backend.session_id = session&.id
|
|
2617
2478
|
@chat_backend
|
|
2618
2479
|
end
|
|
@@ -2640,8 +2501,6 @@ module Samagotchi
|
|
|
2640
2501
|
# start) waits for the first turn, beside the load warnings: no UI
|
|
2641
2502
|
# is there yet, and the Engine isn't built.
|
|
2642
2503
|
@extension_load.load_plugins(registries, failures: @plugin_failures)
|
|
2643
|
-
# Declared once, from what the sources published while they loaded.
|
|
2644
|
-
@tool_index.declare(@tools, failures: @plugin_failures)
|
|
2645
2504
|
end
|
|
2646
2505
|
|
|
2647
2506
|
# Keep a plugin's new tool set (chi.replace_tools, from any thread)
|
|
@@ -2690,6 +2549,7 @@ module Samagotchi
|
|
|
2690
2549
|
state_dir: -> { session_state_dir },
|
|
2691
2550
|
can_stage: -> { !turn_running? && !history? },
|
|
2692
2551
|
reset_prompt: -> { @prompt_builder&.reset! },
|
|
2552
|
+
refresh_index: -> { @tool_index.refresh! },
|
|
2693
2553
|
emit: ->(sink, event) { emit_event(sink, event) },
|
|
2694
2554
|
notify: ->(text, level, source, extra:) { hook_notify(text, level, source, extra: extra) }
|
|
2695
2555
|
)
|
|
@@ -2793,7 +2653,7 @@ module Samagotchi
|
|
|
2793
2653
|
# and bare model name, as a turn resolves them.
|
|
2794
2654
|
# @return [IdleTarget]
|
|
2795
2655
|
def session_model_recap_target
|
|
2796
|
-
IdleTarget.of_model(@host_registry,
|
|
2656
|
+
IdleTarget.of_model(@host_registry, effective_model_name)
|
|
2797
2657
|
end
|
|
2798
2658
|
|
|
2799
2659
|
# Build the idle reminders job. Always created (reminders are opt-in
|
|
@@ -2840,29 +2700,26 @@ module Samagotchi
|
|
|
2840
2700
|
@steer_cut.observe(event, progress)
|
|
2841
2701
|
case event[:type]
|
|
2842
2702
|
when :generation_started
|
|
2843
|
-
@
|
|
2703
|
+
@thinking_notices.generation_started
|
|
2844
2704
|
watch&.started(event[:iteration])
|
|
2845
2705
|
when :generation_retrying
|
|
2846
2706
|
# A dropped stream's step asked again: it streams from the start.
|
|
2847
2707
|
if event[:restarted]
|
|
2848
|
-
@
|
|
2708
|
+
@thinking_notices.generation_started
|
|
2849
2709
|
watch&.started(event[:iteration], restarted: true)
|
|
2850
2710
|
end
|
|
2851
|
-
when :generation_chunk then
|
|
2711
|
+
when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
|
|
2852
2712
|
end
|
|
2853
2713
|
# The chat loop asked again without the thinking fields or the
|
|
2854
2714
|
# reasoning sent back: a notice, not an event of its own.
|
|
2855
|
-
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2856
|
-
next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2857
|
-
next sampling_refused(event) if event[:type] == :sampling_refused
|
|
2715
|
+
next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
|
|
2716
|
+
next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2717
|
+
next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
|
|
2858
2718
|
|
|
2859
2719
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2860
2720
|
emit_event(on_event, event)
|
|
2861
2721
|
watch.feed(**progress) if watch && progress
|
|
2862
|
-
if event[:type] == :generation_completed
|
|
2863
|
-
check_thinking_honoured(event)
|
|
2864
|
-
announce_effort_ignored
|
|
2865
|
-
end
|
|
2722
|
+
@thinking_notices.generation_completed(event) if event[:type] == :generation_completed
|
|
2866
2723
|
end
|
|
2867
2724
|
end
|
|
2868
2725
|
|
|
@@ -2919,38 +2776,13 @@ module Samagotchi
|
|
|
2919
2776
|
emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
|
|
2920
2777
|
end
|
|
2921
2778
|
|
|
2922
|
-
# The window as the target's loop would see it (see ChatLoop#context_window:
|
|
2923
|
-
# a remote chat host has no /props, only its model list).
|
|
2924
|
-
def current_context_window(target)
|
|
2925
|
-
client = target.client
|
|
2926
|
-
adapter = nil
|
|
2927
|
-
if target.entry.chat?
|
|
2928
|
-
adapter = @host_registry.adapter_for(target.entry)
|
|
2929
|
-
client = nil if adapter.remote?
|
|
2930
|
-
end
|
|
2931
|
-
ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter,
|
|
2932
|
-
setting: ContextWindow.setting(target, names: model_lookup_names(target)))
|
|
2933
|
-
rescue StandardError
|
|
2934
|
-
nil
|
|
2935
|
-
end
|
|
2936
|
-
|
|
2937
2779
|
# The effective model's models:/hosts: window_tokens, read per turn
|
|
2938
2780
|
# (after the hooks that may switch the model).
|
|
2939
|
-
def turn_window_setting
|
|
2940
|
-
target = @host_registry.resolve(@effective_model_name)
|
|
2941
|
-
ContextWindow.setting(target, names: model_lookup_names(target))
|
|
2942
|
-
rescue StandardError
|
|
2943
|
-
nil
|
|
2944
|
-
end
|
|
2781
|
+
def turn_window_setting = model_setting(nil, &:window_setting)
|
|
2945
2782
|
|
|
2946
2783
|
# The effective model's price on its host (hosts.<name>.models.<id>.price),
|
|
2947
2784
|
# by the requested id, never the served one; read per turn as the window is.
|
|
2948
|
-
def turn_price
|
|
2949
|
-
target = @host_registry.resolve(@effective_model_name)
|
|
2950
|
-
target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.price
|
|
2951
|
-
rescue StandardError
|
|
2952
|
-
nil
|
|
2953
|
-
end
|
|
2785
|
+
def turn_price = model_setting(nil, &:price)
|
|
2954
2786
|
|
|
2955
2787
|
# The LLM context strategy the next turn runs under: the session's own
|
|
2956
2788
|
# (Session#llm_context), else the effective model's (models:/hosts:
|
|
@@ -2964,57 +2796,82 @@ module Samagotchi
|
|
|
2964
2796
|
private :llm_context_session_args
|
|
2965
2797
|
|
|
2966
2798
|
# See #served_model. A report for another name (before a /model switch)
|
|
2967
|
-
# doesn't count. Without a
|
|
2968
|
-
# /props: nil until a turn.
|
|
2969
|
-
|
|
2970
|
-
|
|
2799
|
+
# doesn't count. Without a model (EffectiveModel), no probe. A remote
|
|
2800
|
+
# chat host has no /props: nil until a turn. The name asked is never
|
|
2801
|
+
# resolved (HostRegistry#bare_name), so it holds on a broken resolve.
|
|
2802
|
+
def served_model_for(snapshot, model: nil)
|
|
2803
|
+
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
2971
2804
|
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
2972
|
-
return [nil, nil] unless
|
|
2805
|
+
return [nil, nil] unless model
|
|
2973
2806
|
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
return [nil, nil] if adapter.remote?
|
|
2978
|
-
end
|
|
2979
|
-
served = ServedModel.from_props(client.server_props(model: target.bare_model))
|
|
2980
|
-
served ? [served, asked] : [nil, nil]
|
|
2807
|
+
model.served_from_props(asked)
|
|
2808
|
+
# Only bare_model_name and the snapshot lookup are left to fail here:
|
|
2809
|
+
# the probe's failure is EffectiveModel#served_from_props's.
|
|
2981
2810
|
rescue StandardError
|
|
2982
2811
|
[nil, nil]
|
|
2983
2812
|
end
|
|
2984
2813
|
|
|
2985
|
-
#
|
|
2986
|
-
|
|
2987
|
-
def prompt_served_model(target)
|
|
2988
|
-
entry = target.entry
|
|
2989
|
-
return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
|
|
2814
|
+
# The window as +model+'s loop would see it (EffectiveModel#context_window).
|
|
2815
|
+
def current_context_window(model) = model.context_window
|
|
2990
2816
|
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2817
|
+
# +settings+' chips (SessionSetting), the model's from +model+ (nil
|
|
2818
|
+
# when it doesn't resolve): /stats, the session state, a :command_ran.
|
|
2819
|
+
def setting_chips(settings, model)
|
|
2820
|
+
settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
|
|
2995
2821
|
end
|
|
2996
2822
|
|
|
2997
|
-
#
|
|
2823
|
+
# The effective model now (EffectiveModel.of): resolved on each call,
|
|
2824
|
+
# never kept (config.yml, a model listing and another process's saved
|
|
2825
|
+
# lists all move a resolve). Raises as HostRegistry#resolve does.
|
|
2826
|
+
# @return [EffectiveModel]
|
|
2827
|
+
def effective_model(choice = @model_choice) = EffectiveModel.of(@host_registry, choice)
|
|
2998
2828
|
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
2829
|
+
# The block's answer for the effective model, or +fallback+ when the
|
|
2830
|
+
# model doesn't resolve: the readers whose rescue always covered the
|
|
2831
|
+
# resolve too. Each setting's own failure is the EffectiveModel's.
|
|
2832
|
+
def model_setting(fallback)
|
|
2833
|
+
# One read: the log line names the model that failed, even if a
|
|
2834
|
+
# /model switched it meanwhile.
|
|
2835
|
+
choice = @model_choice
|
|
2836
|
+
model = begin
|
|
2837
|
+
effective_model(choice)
|
|
2838
|
+
rescue StandardError => e
|
|
2839
|
+
return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
|
|
3002
2840
|
end
|
|
2841
|
+
yield model
|
|
2842
|
+
end
|
|
3003
2843
|
|
|
3004
|
-
|
|
3005
|
-
|
|
2844
|
+
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
2845
|
+
|
|
2846
|
+
# +choice+'s profile (ModelProfile.resolve; may probe /props), or the
|
|
2847
|
+
# one given to .new.
|
|
2848
|
+
def resolve_profile(choice)
|
|
2849
|
+
given = @given_profile
|
|
2850
|
+
return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
|
|
2851
|
+
|
|
2852
|
+
EffectiveModel.of(@host_registry, choice).profile_resolution
|
|
3006
2853
|
end
|
|
3007
2854
|
|
|
3008
|
-
#
|
|
3009
|
-
#
|
|
3010
|
-
|
|
3011
|
-
|
|
2855
|
+
# Keeps +resolution+ as +choice+'s and applies it, unless the model
|
|
2856
|
+
# switched while it was resolved (a /stats on a Bridge thread probing
|
|
2857
|
+
# as a /model ran): then it is only returned, and the new model's is
|
|
2858
|
+
# resolved on its first need.
|
|
2859
|
+
# @return [ModelProfile::Resolution]
|
|
2860
|
+
def store_profile(choice, resolution)
|
|
2861
|
+
@profile_mutex.synchronize do
|
|
2862
|
+
return resolution unless @model_choice.equal?(choice)
|
|
2863
|
+
|
|
2864
|
+
apply_profile(resolution)
|
|
2865
|
+
@profile_resolution = [choice, resolution]
|
|
2866
|
+
end
|
|
2867
|
+
resolution
|
|
3012
2868
|
end
|
|
3013
2869
|
|
|
3014
2870
|
# Everything that holds a profile follows the resolution: the kernel's
|
|
3015
2871
|
# prompt format and parser, and the system prompts built for the old one.
|
|
3016
2872
|
def apply_profile(resolution)
|
|
3017
|
-
|
|
2873
|
+
kept = @profile_resolution&.last
|
|
2874
|
+
@prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
|
|
3018
2875
|
@kernel.use_profile!(resolution)
|
|
3019
2876
|
resolution
|
|
3020
2877
|
end
|
|
@@ -3023,11 +2880,10 @@ module Samagotchi
|
|
|
3023
2880
|
# server probe failed (unreachable, or 503 while loading a model). A
|
|
3024
2881
|
# profile that changes here drops the cached system prompts.
|
|
3025
2882
|
def refresh_profile!
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
end
|
|
2883
|
+
choice, kept = @profile_resolution
|
|
2884
|
+
return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
|
|
2885
|
+
|
|
2886
|
+
store_profile(choice, resolve_profile(choice))
|
|
3031
2887
|
end
|
|
3032
2888
|
end
|
|
3033
2889
|
end
|