samagotchi 0.10.0 → 0.11.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 +78 -1
- data/docs/architecture.md +19 -0
- data/docs/cli.md +24 -7
- data/docs/configuration.md +44 -11
- data/docs/guardrails.md +3 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +10 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +8 -17
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +64 -20
- data/lib/samagotchi/host_registry.rb +50 -90
- data/lib/samagotchi/kernel_loop.rb +288 -305
- data/lib/samagotchi/llm/chat_loop.rb +78 -87
- data/lib/samagotchi/model_profile.rb +22 -3
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/self_report.rb +11 -6
- data/lib/samagotchi/send_command.rb +8 -8
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +10 -12
- data/lib/samagotchi/session_manager.rb +20 -3
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +9 -8
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +6 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -2
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/worker.rb +2 -1
- metadata +6 -1
data/lib/samagotchi/config.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "set"
|
|
|
8
8
|
require_relative "atomic_file"
|
|
9
9
|
require_relative "config_text_edit"
|
|
10
10
|
require_relative "log"
|
|
11
|
+
require_relative "model_ref"
|
|
11
12
|
require_relative "paths"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
@@ -436,7 +437,44 @@ module Samagotchi
|
|
|
436
437
|
else
|
|
437
438
|
[key_problem(key)].compact
|
|
438
439
|
end
|
|
440
|
+
end + model_ref_problems(data)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# What a model ref in config.yml won't do as written: an alias whose
|
|
444
|
+
# target is another alias (aliases apply once, ModelRef), a ref that
|
|
445
|
+
# starts with "<host>/" (only ':' names a host).
|
|
446
|
+
def model_ref_problems(data)
|
|
447
|
+
hosts = data["hosts"].is_a?(Hash) ? data["hosts"] : {}
|
|
448
|
+
aliases = data["model_aliases"].is_a?(Hash) ? data["model_aliases"] : {}
|
|
449
|
+
default = data["default"]["model"] if data["default"].is_a?(Hash)
|
|
450
|
+
refs = [["default.model", default], *aliases.map { |name, target| ["model_aliases.#{name}", target] }]
|
|
451
|
+
slash = refs.filter_map { |where, ref| host_slash_problem(where, ref, hosts) }
|
|
452
|
+
names = aliases.keys.map { |k| k.to_s.strip.downcase }
|
|
453
|
+
chains = aliases.filter_map do |name, target|
|
|
454
|
+
_, inner = ModelRef.split(target, hosts: hosts)
|
|
455
|
+
next unless names.include?(inner.downcase)
|
|
456
|
+
|
|
457
|
+
"config: model_aliases.#{name} points to the alias '#{inner}'; aliases don't chain, so '#{inner}' is sent as written"
|
|
439
458
|
end
|
|
459
|
+
slash + chains + hosts.keys.filter_map { |name| family_host_problem(name.to_s) }
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Model families whose ids carry a ':' tag on Ollama and friends
|
|
463
|
+
# (qwen3:8b, llama3.1:70b): a host named like one takes those refs.
|
|
464
|
+
MODEL_FAMILY_HOST_RE = /\A(?:qwen|llama|codellama|gemma|mistral|mixtral|deepseek|phi|granite|starcoder|smollm|olmo)[\d.]*\z/i
|
|
465
|
+
|
|
466
|
+
def family_host_problem(name)
|
|
467
|
+
return nil unless name.match?(MODEL_FAMILY_HOST_RE)
|
|
468
|
+
|
|
469
|
+
"config: hosts.#{name} is named like a model family: a ref such as '#{name}:8b' (an Ollama tag) " \
|
|
470
|
+
"goes to host #{name} as '8b'; rename the host if you use such ids"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def host_slash_problem(where, ref, hosts)
|
|
474
|
+
prefix, rest = ref.to_s.strip.split("/", 2)
|
|
475
|
+
return nil if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
|
|
476
|
+
|
|
477
|
+
"config: #{where} '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now (#{prefix}:#{rest})"
|
|
440
478
|
end
|
|
441
479
|
|
|
442
480
|
# Dotted keys config.yml may set: config-exposed entries (snake and kebab
|
|
@@ -831,28 +869,9 @@ module Samagotchi
|
|
|
831
869
|
end
|
|
832
870
|
|
|
833
871
|
# Parse a model string that may be qualified as "host_alias:model"
|
|
834
|
-
# Returns [host_alias_or_nil, bare_model]
|
|
872
|
+
# Returns [host_alias_or_nil, bare_model] (ModelRef.split)
|
|
835
873
|
def parse_host_qualified_model(raw, hosts: nil)
|
|
836
|
-
|
|
837
|
-
return [nil, value] if value.empty?
|
|
838
|
-
# Try split on first ':' or '/' where prefix matches a known host
|
|
839
|
-
hosts_map = hosts || {}
|
|
840
|
-
# Normalize keys downcase
|
|
841
|
-
lowered_keys = hosts_map.keys.map(&:downcase)
|
|
842
|
-
# Check ':' split
|
|
843
|
-
if value.include?(":")
|
|
844
|
-
prefix, rest = value.split(":", 2)
|
|
845
|
-
if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
|
|
846
|
-
return [prefix.strip.downcase, rest.strip]
|
|
847
|
-
end
|
|
848
|
-
end
|
|
849
|
-
if value.include?("/")
|
|
850
|
-
prefix, rest = value.split("/", 2)
|
|
851
|
-
if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
|
|
852
|
-
return [prefix.strip.downcase, rest.strip]
|
|
853
|
-
end
|
|
854
|
-
end
|
|
855
|
-
[nil, value]
|
|
874
|
+
ModelRef.split(raw, hosts: hosts)
|
|
856
875
|
end
|
|
857
876
|
|
|
858
877
|
# Hosted providers' names, which no local model family uses: as a
|
|
@@ -989,25 +1008,10 @@ module Samagotchi
|
|
|
989
1008
|
nil
|
|
990
1009
|
end
|
|
991
1010
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
aliases = model_aliases(env: env, path: path)
|
|
997
|
-
# Host-qualified handling: "host:alias" -> "host:resolved"
|
|
998
|
-
# This allows /model small-box:small where "small" is an alias.
|
|
999
|
-
if value.include?(":") || value.include?("/")
|
|
1000
|
-
hosts_map = hosts || hosts_config(env: env, path: path)
|
|
1001
|
-
host, bare = parse_host_qualified_model(value, hosts: hosts_map)
|
|
1002
|
-
if host && !bare.to_s.strip.empty?
|
|
1003
|
-
resolved_bare = aliases.fetch(bare.downcase, bare)
|
|
1004
|
-
# Preserve the separator the user used (: or /)
|
|
1005
|
-
sep = value.downcase.include?("#{host}:") ? ":" : (value.downcase.include?("#{host}/") ? "/" : ":")
|
|
1006
|
-
return "#{host}#{sep}#{resolved_bare}"
|
|
1007
|
-
end
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
aliases.fetch(value.downcase, value)
|
|
1011
|
+
# +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
|
|
1012
|
+
# @return [ModelRef]
|
|
1013
|
+
def model_ref(raw, env: ENV, path: global_path(env: env), hosts: nil)
|
|
1014
|
+
ModelRef.parse(raw, hosts: hosts || hosts_config(env: env, path: path), aliases: model_aliases(env: env, path: path))
|
|
1011
1015
|
end
|
|
1012
1016
|
|
|
1013
1017
|
# Words /model takes as arguments (clear/default/none/off reset the
|
|
@@ -5,7 +5,7 @@ require_relative "context_usage"
|
|
|
5
5
|
require_relative "context_window"
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
|
-
# One turn's context tracking for
|
|
8
|
+
# One turn's context tracking for either loop: how full the window is
|
|
9
9
|
# before each request (the server's last prompt count plus an estimate for
|
|
10
10
|
# what the turn appended since, else a chars-per-token estimate), the
|
|
11
11
|
# bucket that puts it in, and what comes of that:
|
|
@@ -95,18 +95,18 @@ module Samagotchi
|
|
|
95
95
|
|
|
96
96
|
def enabled? = @enabled
|
|
97
97
|
|
|
98
|
-
# Before a request: estimate
|
|
99
|
-
# request's ContextWindow::Resolved) and update
|
|
100
|
-
# value. A rise into a bucket that asks the model for
|
|
101
|
-
# a line for #take_guidance.
|
|
102
|
-
# +image_tokens+: the images' estimate (their base64 is not in
|
|
98
|
+
# Before a request: estimate the usage of a prompt of +prompt_chars+
|
|
99
|
+
# against +window+ (this request's ContextWindow::Resolved) and update
|
|
100
|
+
# the status line's value. A rise into a bucket that asks the model for
|
|
101
|
+
# a change leaves a line for #take_guidance.
|
|
102
|
+
# +image_tokens+: the images' estimate (their base64 is not in the chars).
|
|
103
103
|
#
|
|
104
104
|
# @return [Hash, nil] the :context_status event's fields (without
|
|
105
105
|
# :type/:iteration) when the emit gate fires, else nil
|
|
106
|
-
def observe(
|
|
106
|
+
def observe(prompt_chars, iteration_index:, window:, image_tokens: 0)
|
|
107
107
|
return nil unless @enabled
|
|
108
108
|
|
|
109
|
-
usage = estimate(
|
|
109
|
+
usage = estimate(prompt_chars, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
|
|
110
110
|
bucket = bucket_for(usage[:estimated_pct])
|
|
111
111
|
# The status line's value, every iteration; the gate below decides
|
|
112
112
|
# only the event and the model's guidance line.
|
|
@@ -135,7 +135,8 @@ module Samagotchi
|
|
|
135
135
|
normalized
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# After a generation: +usage+ is its own server counts (
|
|
138
|
+
# After a generation: +usage+ is its own server counts ({prompt_tokens:,
|
|
139
|
+
# total_tokens:, context_window_tokens:}; nil: none), for
|
|
139
140
|
# +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
|
|
140
141
|
# what the turn appended since (answer, tool results), and the status
|
|
141
142
|
# line's value counts the answer. Without counts the pre-generation
|
|
@@ -161,21 +162,21 @@ module Samagotchi
|
|
|
161
162
|
# `window` is this request's ContextWindow::Resolved. A window the
|
|
162
163
|
# stream payload reports itself still wins.
|
|
163
164
|
# +counted+: the prompt the server's prompt_tokens counted ({chars:,
|
|
164
|
-
# image_tokens:}); what
|
|
165
|
+
# image_tokens:}); what the prompt has on top of it (the answer, tool
|
|
165
166
|
# results since) is added as an estimate, so the value doesn't read low
|
|
166
167
|
# during a long tool loop. A prompt shorter than that one (trimmed) or
|
|
167
168
|
# none known: the server's count alone.
|
|
168
|
-
def estimate(
|
|
169
|
+
def estimate(prompt_chars, window:, server_usage: nil, image_tokens: 0, counted: nil)
|
|
169
170
|
window_source = window.source
|
|
170
171
|
window_source = :server if server_usage && server_usage[:context_window_tokens]
|
|
171
172
|
|
|
172
173
|
if server_usage && server_usage[:prompt_tokens]
|
|
173
174
|
window_tokens = server_usage[:context_window_tokens] || window.tokens
|
|
174
|
-
used_tokens = server_usage[:prompt_tokens] + appended_tokens(
|
|
175
|
+
used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
|
|
175
176
|
source = "server"
|
|
176
177
|
else
|
|
177
178
|
window_tokens = window.tokens
|
|
178
|
-
used_tokens = (
|
|
179
|
+
used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
|
|
179
180
|
source = "estimate"
|
|
180
181
|
end
|
|
181
182
|
|
|
@@ -191,11 +192,11 @@ module Samagotchi
|
|
|
191
192
|
|
|
192
193
|
private
|
|
193
194
|
|
|
194
|
-
# The estimate for what
|
|
195
|
-
def appended_tokens(
|
|
195
|
+
# The estimate for what the prompt added since the +counted+ one.
|
|
196
|
+
def appended_tokens(prompt_chars, image_tokens, counted)
|
|
196
197
|
return 0 unless counted
|
|
197
198
|
|
|
198
|
-
chars =
|
|
199
|
+
chars = prompt_chars - counted[:chars]
|
|
199
200
|
return 0 unless chars.positive?
|
|
200
201
|
|
|
201
202
|
(chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
4
5
|
require_relative "turn_note"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# Asking the model again, in the same turn, when a generation ends with no
|
|
8
9
|
# visible text and no tool calls (thinking only, or nothing): a hidden
|
|
9
|
-
# nudge (TurnNote.empty_retry), then another generation.
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# nudge (TurnNote.empty_retry), then another generation. A generation a
|
|
11
|
+
# plugin cut (stop_generation) spends the same budget with its own nudge.
|
|
12
|
+
# Both loops keep one per turn; the Engine's TurnNote.empty is what's left
|
|
13
|
+
# when the retries run out.
|
|
14
|
+
class EmptyAnswerRetry
|
|
12
15
|
MAX = 3
|
|
13
16
|
# Greedy decoding on a near-identical prompt would likely loop again:
|
|
14
17
|
# the retry runs at this temperature unless one is configured.
|
|
@@ -17,10 +20,8 @@ module Samagotchi
|
|
|
17
20
|
# is retried unless the context is this full.
|
|
18
21
|
CONTEXT_FULL_RATIO = 0.9
|
|
19
22
|
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
23
|
# retry.empty_answer, 0..MAX.
|
|
23
|
-
def limit
|
|
24
|
+
def self.limit
|
|
24
25
|
Config.get("retry.empty_answer").to_i.clamp(0, MAX)
|
|
25
26
|
rescue StandardError
|
|
26
27
|
1
|
|
@@ -28,16 +29,74 @@ module Samagotchi
|
|
|
28
29
|
|
|
29
30
|
# The request options of a retry generation: the turn's sampling, with
|
|
30
31
|
# TEMPERATURE when it sets none (a configured nil keeps "don't send").
|
|
31
|
-
def sampling(base)
|
|
32
|
+
def self.sampling(base)
|
|
32
33
|
base = base || {}
|
|
33
34
|
base.key?(:temperature) ? base : base.merge(temperature: TEMPERATURE)
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# The prompt filled the window (a length stop is then no loop to break).
|
|
37
|
-
def context_full?(used_tokens, window_tokens)
|
|
38
|
+
def self.context_full?(used_tokens, window_tokens)
|
|
38
39
|
return false unless used_tokens.to_i.positive? && window_tokens.to_i.positive?
|
|
39
40
|
|
|
40
41
|
used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
|
|
41
42
|
end
|
|
43
|
+
|
|
44
|
+
attr_reader :limit
|
|
45
|
+
|
|
46
|
+
def initialize(limit: self.class.limit)
|
|
47
|
+
@limit = limit
|
|
48
|
+
@attempts = 0
|
|
49
|
+
@armed = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An attempt is left.
|
|
53
|
+
def left? = @attempts < @limit
|
|
54
|
+
|
|
55
|
+
# A nudge went out this turn.
|
|
56
|
+
def used? = @attempts.positive?
|
|
57
|
+
|
|
58
|
+
# An empty answer is asked again: an attempt is left, the turn wasn't
|
|
59
|
+
# cancelled, and the answer wasn't cut short by a full context (a
|
|
60
|
+
# length stop while thinking is retried: a thinking loop cut by the
|
|
61
|
+
# output cap, not a full window).
|
|
62
|
+
def retry_empty?(iteration:, cancelled:, finish_reason: nil, used_tokens: nil, window_tokens: nil)
|
|
63
|
+
return false if !left? || cancelled
|
|
64
|
+
|
|
65
|
+
if finish_reason.to_s == "length" && self.class.context_full?(used_tokens, window_tokens)
|
|
66
|
+
Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Spends an attempt: :empty_answer_retry (with +fields+) goes out, +note+
|
|
73
|
+
# goes on the tail, and the next request runs at the retry sampling.
|
|
74
|
+
# Returns true.
|
|
75
|
+
def nudge!(conversation, note, emit:, iteration:, **fields)
|
|
76
|
+
@attempts += 1
|
|
77
|
+
@armed = true
|
|
78
|
+
emit.call({ type: :empty_answer_retry, iteration: iteration, attempt: @attempts, of: @limit, **fields })
|
|
79
|
+
conversation << note
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether this request is the retry (once per nudge).
|
|
84
|
+
def take_sampling!
|
|
85
|
+
armed = @armed
|
|
86
|
+
@armed = false
|
|
87
|
+
armed
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# This request's sampling: the retry's once after a nudge, else +base+.
|
|
91
|
+
def request_sampling(base)
|
|
92
|
+
take_sampling! ? self.class.sampling(base) : base
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The last spent nudge goes (a retry that failed too: the Engine's
|
|
96
|
+
# TurnNote.empty says it all).
|
|
97
|
+
def drop_nudge!(conversation)
|
|
98
|
+
index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
|
|
99
|
+
conversation.delete_at(index) if index
|
|
100
|
+
end
|
|
42
101
|
end
|
|
43
102
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -76,20 +76,27 @@ module Samagotchi
|
|
|
76
76
|
# @param client [Client, nil] defaults to Client.new
|
|
77
77
|
# @param profile [ModelProfile, Symbol, String, nil]
|
|
78
78
|
# @param session_id [String, nil] resume an existing session
|
|
79
|
-
# @param no_interrupt [Boolean]
|
|
79
|
+
# @param no_interrupt [Boolean] --no-interrupt: every turn runs with
|
|
80
|
+
# NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
|
|
80
81
|
# @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
|
|
82
|
+
# @param model_typed [String, nil] the name the model was given as (a session's
|
|
83
|
+
# model_typed: an alias), for the models: lookup; model_name by default
|
|
81
84
|
# @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
|
|
82
85
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
83
86
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
84
87
|
# What memory_write answers in a scratch session.
|
|
85
88
|
SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
|
|
89
|
+
# A turn's iteration limit with no_interrupt (the worker's own
|
|
90
|
+
# --no-interrupt turns pass the same).
|
|
91
|
+
NO_INTERRUPT_MAX_ITERATIONS = 1000
|
|
86
92
|
|
|
87
93
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
88
94
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
89
95
|
# delegate (a child would outlive it) nor plugin fork
|
|
90
96
|
def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
91
|
-
plugins: true, scratch: false)
|
|
97
|
+
plugins: true, scratch: false, model_typed: nil)
|
|
92
98
|
@scratch = scratch
|
|
99
|
+
@no_interrupt = no_interrupt
|
|
93
100
|
@chat_backend = nil
|
|
94
101
|
@chat_backend_mutex = Mutex.new
|
|
95
102
|
@host_registry = host_registry || HostRegistry.new
|
|
@@ -106,7 +113,7 @@ module Samagotchi
|
|
|
106
113
|
# #profile_resolution decides on first need (see there), so building an
|
|
107
114
|
# Engine makes no network call.
|
|
108
115
|
@given_profile = profile ? ModelProfile.normalize(profile) : nil
|
|
109
|
-
@typed_model_name = @default_model_name
|
|
116
|
+
@typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
|
|
110
117
|
@profile_resolution = nil
|
|
111
118
|
# Ensure the built-in system bundle is installed (lazy, warn-only).
|
|
112
119
|
# This is the single seam for both TUI and non-TUI (web/worker) paths.
|
|
@@ -196,11 +203,10 @@ module Samagotchi
|
|
|
196
203
|
@reminders = build_reminders(auto_turn_callback: callback)
|
|
197
204
|
# Track whether this is the first turn in the session (for session_start event)
|
|
198
205
|
@first_turn = true
|
|
199
|
-
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile,
|
|
206
|
+
@kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
|
|
200
207
|
tools: @tools)
|
|
201
208
|
sync_kernel_client!
|
|
202
|
-
|
|
203
|
-
@kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
|
|
209
|
+
sync_model_key!
|
|
204
210
|
# The mutes never change during a session, so no re-sync: the kernel's
|
|
205
211
|
# memory_read guard reads the same list for every turn.
|
|
206
212
|
@muted_memory_names = MutedMemories.normalize_list(muted_memories)
|
|
@@ -426,7 +432,30 @@ module Samagotchi
|
|
|
426
432
|
|
|
427
433
|
# @return [SessionMetrics] the per-session analytics collector
|
|
428
434
|
attr_reader :metrics
|
|
429
|
-
|
|
435
|
+
# effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
|
|
436
|
+
# applies it); typed_model_name: as typed at start or at the last /model.
|
|
437
|
+
attr_reader :default_model_name, :effective_model_name, :typed_model_name
|
|
438
|
+
|
|
439
|
+
# The resolved ref a session stores for the effective model (ModelRef#ref:
|
|
440
|
+
# the alias applied, "host:id" when it names a host).
|
|
441
|
+
def effective_model_ref
|
|
442
|
+
model_ref_for(@effective_model_name)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# +name+'s resolved ref (ModelRef#ref).
|
|
446
|
+
def model_ref_for(name)
|
|
447
|
+
@host_registry.model_ref(name).ref
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Writes the effective model to +session+: the resolved ref, and the
|
|
451
|
+
# name as typed when that differs (model_typed: an alias, for the
|
|
452
|
+
# models: lookup after a resume).
|
|
453
|
+
def store_model!(session)
|
|
454
|
+
ref = effective_model_ref
|
|
455
|
+
session.model_name = ref
|
|
456
|
+
session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name if session.respond_to?(:model_typed=)
|
|
457
|
+
session
|
|
458
|
+
end
|
|
430
459
|
|
|
431
460
|
# The effective model's key (ModelOverlay.key_for its bare name): memory
|
|
432
461
|
# overlays, guardrail rules' models:.
|
|
@@ -453,10 +482,23 @@ module Samagotchi
|
|
|
453
482
|
# built-ins, and the ones bundle plugins add
|
|
454
483
|
attr_reader :command_registry
|
|
455
484
|
|
|
485
|
+
# The model id sent for +full_ref+ (its alias applied, the host prefix off).
|
|
456
486
|
def bare_model_name(full_ref)
|
|
457
487
|
@host_registry.bare_name(full_ref)
|
|
458
488
|
end
|
|
459
489
|
|
|
490
|
+
# The memory overlay key follows the model sent (ModelOverlay.key_for
|
|
491
|
+
# its id). A model typed as an alias used to key overlays by the alias
|
|
492
|
+
# (before aliases were applied there): that key is read when the
|
|
493
|
+
# target's overlay is missing (KernelLoop#sync_model_key!).
|
|
494
|
+
def sync_model_key!
|
|
495
|
+
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
496
|
+
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
497
|
+
fallback = typed_key == @model_key ? nil : typed_key
|
|
498
|
+
@kernel.sync_model_key!(@model_key, fallback: fallback) if @kernel.respond_to?(:sync_model_key!)
|
|
499
|
+
end
|
|
500
|
+
private :sync_model_key!
|
|
501
|
+
|
|
460
502
|
# The effective model's bare name for the guardrails' models: rules;
|
|
461
503
|
# nil without a model name.
|
|
462
504
|
def guardrail_model_name
|
|
@@ -762,27 +804,29 @@ module Samagotchi
|
|
|
762
804
|
|
|
763
805
|
# ── Model switching ────────────────────────────────────────────────────────
|
|
764
806
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
807
|
+
# @param typed [String, nil] the name the model was typed as when
|
|
808
|
+
# +model_name+ is a stored ref (a resumed session's model_typed)
|
|
809
|
+
# @return [String] the resolved ref the session stores (ModelRef#ref)
|
|
810
|
+
def switch_model!(model_name, persist_default: false, typed: nil)
|
|
811
|
+
# The alias is applied where the model is resolved (HostRegistry#resolve),
|
|
812
|
+
# once; checked here: an unknown host, an alias naming another host.
|
|
813
|
+
resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
|
|
769
814
|
@effective_model_name = resolved
|
|
770
|
-
|
|
771
|
-
@typed_model_name = model_name
|
|
815
|
+
@typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
|
|
772
816
|
# A profile given to .new was for the starting model.
|
|
773
817
|
@given_profile = nil
|
|
774
818
|
@profile_resolution = nil
|
|
775
|
-
|
|
776
|
-
@kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
|
|
819
|
+
sync_model_key!
|
|
777
820
|
@prompt_builder.reset!
|
|
778
821
|
sync_kernel_client!
|
|
779
822
|
@client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
|
|
780
823
|
@metrics.forget_model_reports!
|
|
824
|
+
ref = effective_model_ref
|
|
781
825
|
if persist_default
|
|
782
|
-
ConfigFile.write_default_model!(
|
|
783
|
-
@default_model_name =
|
|
826
|
+
ConfigFile.write_default_model!(ref)
|
|
827
|
+
@default_model_name = ref
|
|
784
828
|
end
|
|
785
|
-
|
|
829
|
+
ref
|
|
786
830
|
end
|
|
787
831
|
|
|
788
832
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
@@ -968,7 +1012,7 @@ module Samagotchi
|
|
|
968
1012
|
preloaded_memory_names: preloaded_memory_names,
|
|
969
1013
|
muted_memory_names: @muted_memory_names.dup,
|
|
970
1014
|
parent_id: @session&.parent_id,
|
|
971
|
-
model_name:
|
|
1015
|
+
model_name: effective_model_ref,
|
|
972
1016
|
served_model: served_pair[0],
|
|
973
1017
|
served_model_for: served_pair[1],
|
|
974
1018
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
@@ -1580,7 +1624,7 @@ module Samagotchi
|
|
|
1580
1624
|
|
|
1581
1625
|
result = backend.complete(
|
|
1582
1626
|
messages: messages,
|
|
1583
|
-
max_iterations: max_iterations,
|
|
1627
|
+
max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
|
|
1584
1628
|
on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
|
|
1585
1629
|
cancel_controller: effective_controller,
|
|
1586
1630
|
model_name: bare_for_backend,
|