samagotchi 0.55.0 → 0.57.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 +65 -1
- data/README.md +1 -1
- data/docs/architecture.md +6 -2
- data/docs/cli.md +30 -8
- data/docs/configuration.md +84 -8
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +14 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +7 -3
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +19 -3
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +29 -6
- data/lib/samagotchi/web/public/app.js +44 -9
- data/lib/samagotchi/web/public/commands.js +36 -3
- data/lib/samagotchi/web/public/composer.js +5 -1
- data/lib/samagotchi/web/public/index.html +22 -5
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +10 -2
- data/lib/samagotchi/web/public/thinking_chip.js +33 -138
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +17 -5
- metadata +13 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -62,6 +62,7 @@ require_relative "vision_context"
|
|
|
62
62
|
require_relative "vision_support"
|
|
63
63
|
require_relative "sampling_settings"
|
|
64
64
|
require_relative "thinking"
|
|
65
|
+
require_relative "reasoning_history"
|
|
65
66
|
require_relative "answer_display"
|
|
66
67
|
require_relative "edit_preview"
|
|
67
68
|
require_relative "question_desk"
|
|
@@ -1010,7 +1011,8 @@ module Samagotchi
|
|
|
1010
1011
|
served, served_for = served_model_for(snapshot, target: target)
|
|
1011
1012
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1012
1013
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
1013
|
-
thinking: thinking_explained&.summary,
|
|
1014
|
+
thinking: thinking_explained&.summary,
|
|
1015
|
+
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
|
|
1014
1016
|
unless snapshot.dig(:context, :window_tokens)
|
|
1015
1017
|
window = current_context_window(target)
|
|
1016
1018
|
if window
|
|
@@ -1093,16 +1095,80 @@ module Samagotchi
|
|
|
1093
1095
|
end
|
|
1094
1096
|
|
|
1095
1097
|
# The effective model's thinking level with where it came from and the
|
|
1096
|
-
# session's own (
|
|
1098
|
+
# session's own (SettingExplained), as the next turn resolves it;
|
|
1097
1099
|
# nil when it can't be resolved.
|
|
1098
|
-
# @return [
|
|
1099
|
-
|
|
1100
|
+
# @return [SettingExplained, nil]
|
|
1101
|
+
# +own+: the session's own level to resolve with (the current one by
|
|
1102
|
+
# default; /thinking asks what a change would resolve to).
|
|
1103
|
+
def thinking_explained(own: thinking_override)
|
|
1100
1104
|
target = @host_registry.resolve(@effective_model_name)
|
|
1101
|
-
Thinking.explain(target, names: model_lookup_names(target), session:
|
|
1105
|
+
Thinking.explain(target, names: model_lookup_names(target), session: own)
|
|
1102
1106
|
rescue StandardError
|
|
1103
1107
|
nil
|
|
1104
1108
|
end
|
|
1105
1109
|
|
|
1110
|
+
# "drop (models: qwen)" for /model: the effective chat model's
|
|
1111
|
+
# reasoning_history level and where it came from; nil when it is the
|
|
1112
|
+
# default, the model runs on the native loop (which ignores it) or it
|
|
1113
|
+
# can't be resolved.
|
|
1114
|
+
def reasoning_history_summary
|
|
1115
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1116
|
+
return nil unless target.entry&.chat?
|
|
1117
|
+
|
|
1118
|
+
level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
|
|
1119
|
+
session: reasoning_history_override)
|
|
1120
|
+
source ? ReasoningHistory.label(level, source) : nil
|
|
1121
|
+
rescue StandardError
|
|
1122
|
+
nil
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# The effective chat model's reasoning_history level with where it came
|
|
1126
|
+
# from and the session's own (SettingExplained), as the next turn
|
|
1127
|
+
# resolves it; nil on a native host (the native loop ignores it; native:
|
|
1128
|
+
# true resolves it there too, for /reasoning-history) or when it can't
|
|
1129
|
+
# be resolved.
|
|
1130
|
+
# @return [SettingExplained, nil]
|
|
1131
|
+
# +own+: the session's own level to resolve with (the current one by
|
|
1132
|
+
# default; a command asks what a change would resolve to).
|
|
1133
|
+
def reasoning_history_explained(native: false, own: reasoning_history_override)
|
|
1134
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1135
|
+
return nil unless native || target.entry&.chat?
|
|
1136
|
+
|
|
1137
|
+
ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
|
|
1138
|
+
rescue StandardError
|
|
1139
|
+
nil
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
# What a reasoning_history change from level +from+ to level +to+ costs
|
|
1143
|
+
# the effective model's prompt cache, given the session's conversation
|
|
1144
|
+
# (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
|
|
1145
|
+
# marked as refusing it may still get :history (one needless ask).
|
|
1146
|
+
# @return [Symbol, nil]
|
|
1147
|
+
def reasoning_history_cache_cost(from, to)
|
|
1148
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1149
|
+
ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
|
|
1150
|
+
rescue StandardError
|
|
1151
|
+
nil
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Whether the session has a model reply (a cache to restart at all).
|
|
1155
|
+
def history?
|
|
1156
|
+
Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
# The session's own reasoning_history level, nil when it has none.
|
|
1160
|
+
# @return [Symbol, nil]
|
|
1161
|
+
def reasoning_history_override = @session&.reasoning_history
|
|
1162
|
+
|
|
1163
|
+
# Set the session's own reasoning_history level (nil unsets it); the
|
|
1164
|
+
# next turn's start resolves it. The caller saves the session.
|
|
1165
|
+
# @raise [ArgumentError] with no session yet
|
|
1166
|
+
def reasoning_history_override=(level)
|
|
1167
|
+
raise ArgumentError, "no session yet" unless @session
|
|
1168
|
+
|
|
1169
|
+
@session.reasoning_history = level
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1106
1172
|
# The session's own thinking level, nil when it has none.
|
|
1107
1173
|
# @return [Symbol, nil]
|
|
1108
1174
|
def thinking_override = @session&.thinking
|
|
@@ -1192,6 +1258,7 @@ module Samagotchi
|
|
|
1192
1258
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1193
1259
|
llm_context: llm_context_summary,
|
|
1194
1260
|
thinking: thinking_explained&.summary,
|
|
1261
|
+
reasoning_history: reasoning_history_explained&.summary,
|
|
1195
1262
|
recap_enabled: !@recap.nil?,
|
|
1196
1263
|
recap_min_user_turns: @recap&.min_user_turns,
|
|
1197
1264
|
recap_inactivity_seconds: @recap&.inactivity&.to_i
|
|
@@ -1926,7 +1993,8 @@ module Samagotchi
|
|
|
1926
1993
|
thinking_target = @host_registry.resolve(@effective_model_name)
|
|
1927
1994
|
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1928
1995
|
announce_thinking_level(*@turn_thinking)
|
|
1929
|
-
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
|
|
1996
|
+
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
|
|
1997
|
+
reasoning_history: reasoning_history_level(thinking_target), model_name: nil,
|
|
1930
1998
|
window_setting: nil, llm_context: nil, price: nil)
|
|
1931
1999
|
end
|
|
1932
2000
|
private :turn_settings
|
|
@@ -2317,6 +2385,23 @@ module Samagotchi
|
|
|
2317
2385
|
nil
|
|
2318
2386
|
end
|
|
2319
2387
|
|
|
2388
|
+
# The host refused the model's reasoning sent back (reasoning_history)
|
|
2389
|
+
# and the chat loop asked the step again without it, as it will from
|
|
2390
|
+
# now on: logged, and said once per session and host.
|
|
2391
|
+
def reasoning_history_refused(event)
|
|
2392
|
+
_level, target = @turn_thinking
|
|
2393
|
+
return unless target
|
|
2394
|
+
|
|
2395
|
+
Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
2396
|
+
detail: event[:detail])
|
|
2397
|
+
return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
|
|
2398
|
+
|
|
2399
|
+
hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
|
|
2400
|
+
"#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
|
|
2401
|
+
rescue StandardError
|
|
2402
|
+
nil
|
|
2403
|
+
end
|
|
2404
|
+
|
|
2320
2405
|
def thinking_notice_once(kind, target, level, text)
|
|
2321
2406
|
key = [@session&.id, target.entry.name, kind]
|
|
2322
2407
|
return unless (@thinking_notices ||= Set.new).add?(key)
|
|
@@ -2324,6 +2409,14 @@ module Samagotchi
|
|
|
2324
2409
|
hook_notify(text, level, "thinking")
|
|
2325
2410
|
end
|
|
2326
2411
|
|
|
2412
|
+
# +target+'s ReasoningHistory level, the session's own first; the
|
|
2413
|
+
# default when it can't be resolved.
|
|
2414
|
+
def reasoning_history_level(target)
|
|
2415
|
+
ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
|
|
2416
|
+
rescue StandardError
|
|
2417
|
+
ReasoningHistory::DEFAULT
|
|
2418
|
+
end
|
|
2419
|
+
|
|
2327
2420
|
# +target+'s thinking level (Thinking.resolve), the session's own first.
|
|
2328
2421
|
def thinking_level(target)
|
|
2329
2422
|
Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
|
|
@@ -2602,9 +2695,10 @@ module Samagotchi
|
|
|
2602
2695
|
end
|
|
2603
2696
|
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2604
2697
|
end
|
|
2605
|
-
# The chat loop asked again without the thinking fields
|
|
2606
|
-
# not an event of its own.
|
|
2698
|
+
# The chat loop asked again without the thinking fields or the
|
|
2699
|
+
# reasoning sent back: a notice, not an event of its own.
|
|
2607
2700
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2701
|
+
next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2608
2702
|
|
|
2609
2703
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2610
2704
|
emit_event(on_event, event)
|
|
@@ -18,6 +18,7 @@ module Samagotchi
|
|
|
18
18
|
def relay = engine.relay_peer
|
|
19
19
|
def model_ref = engine.effective_model_ref
|
|
20
20
|
def thinking = engine.thinking_override
|
|
21
|
+
def reasoning_history = engine.reasoning_history_override
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
@@ -6,6 +6,7 @@ require "open3"
|
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require "rubygems/package"
|
|
8
8
|
require_relative "version"
|
|
9
|
+
require_relative "redacted_inspect"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# The gem part of `chi update`: the newest samagotchi on rubygems, and a
|
|
@@ -15,6 +16,8 @@ module Samagotchi
|
|
|
15
16
|
# SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
|
|
16
17
|
# file the newest version and installs it instead.
|
|
17
18
|
class GemUpdate
|
|
19
|
+
include RedactedInspect
|
|
20
|
+
|
|
18
21
|
class Error < StandardError
|
|
19
22
|
end
|
|
20
23
|
|
|
@@ -33,6 +33,14 @@ module Samagotchi
|
|
|
33
33
|
Lexer.new(text).tokens
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Whether +text+ ends inside a quote, a $(…) or a heredoc: its tokens
|
|
37
|
+
# are a best guess.
|
|
38
|
+
def self.unterminated?(text)
|
|
39
|
+
lexer = Lexer.new(text)
|
|
40
|
+
lexer.tokens
|
|
41
|
+
lexer.unterminated?
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
# The token stream split at operators into word lists; ( and ) become
|
|
37
45
|
# :open / :close markers.
|
|
38
46
|
def self.simple_commands(tokens)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "shell_lex"
|
|
4
4
|
require_relative "protected_paths"
|
|
5
|
+
require_relative "../redacted_inspect"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Guardrails
|
|
@@ -62,6 +63,8 @@ module Samagotchi
|
|
|
62
63
|
# Follows the current dir and gives, per simple command, each word
|
|
63
64
|
# with its path (nil when it can't be resolved).
|
|
64
65
|
class Walk
|
|
66
|
+
include RedactedInspect
|
|
67
|
+
|
|
65
68
|
def initialize(cwd, home, env)
|
|
66
69
|
@dir = cwd
|
|
67
70
|
@home = home
|
|
@@ -4,6 +4,7 @@ require "uri"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "host_model"
|
|
6
6
|
require_relative "llm_context_strategy"
|
|
7
|
+
require_relative "reasoning_history"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
# One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
|
|
@@ -17,8 +18,9 @@ module Samagotchi
|
|
|
17
18
|
# api: symbols, nil when unset; the rest as HostRegistry::HostEntry
|
|
18
19
|
# describes them, nil when unset; models: {downcased id => HostModel}.
|
|
19
20
|
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
|
|
20
|
-
:first_token_timeout, :vision, :sampling, :thinking, :
|
|
21
|
-
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
21
|
+
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
|
+
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
|
+
:models) do
|
|
22
24
|
# Every field but name may be left out: nil, models {}.
|
|
23
25
|
def initialize(**fields)
|
|
24
26
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
@@ -50,6 +52,7 @@ module Samagotchi
|
|
|
50
52
|
window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
|
|
51
53
|
sampling: ConfigFile.sampling_map(value.call(:sampling), where),
|
|
52
54
|
thinking: Thinking.level(value.call(:thinking), where),
|
|
55
|
+
reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
|
|
53
56
|
llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
|
|
54
57
|
llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
|
|
55
58
|
models: HostModel.parse_map(value.call(:models), name),
|
|
@@ -166,7 +169,7 @@ module Samagotchi
|
|
|
166
169
|
location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
|
|
167
170
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
168
171
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
169
|
-
"remote" => remote, "window_tokens" => window_tokens,
|
|
172
|
+
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
170
173
|
"llm_context_strategy" => llm_context_strategy&.map(&:to_s),
|
|
171
174
|
"llm_context_apply" => llm_context_apply&.to_s,
|
|
172
175
|
"llm_context_budget_tokens" => llm_context_budget_tokens,
|
|
@@ -35,6 +35,7 @@ module Samagotchi
|
|
|
35
35
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
36
36
|
# sampling: the configured request parameters (SamplingSettings), nil when unset;
|
|
37
37
|
# thinking: the configured level (Thinking), nil when unset;
|
|
38
|
+
# reasoning_history: the configured level (ReasoningHistory), nil when unset;
|
|
38
39
|
# remote: the configured true/false (#remote?), nil when unset;
|
|
39
40
|
# window_tokens: the configured context window (ContextWindow.setting), nil when unset;
|
|
40
41
|
# llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
|
|
@@ -42,9 +43,9 @@ module Samagotchi
|
|
|
42
43
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
43
44
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
44
45
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
45
|
-
:first_token_timeout, :vision, :sampling, :thinking, :
|
|
46
|
-
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
47
|
-
keyword_init: true) do
|
|
46
|
+
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
47
|
+
:window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
48
|
+
:models, keyword_init: true) do
|
|
48
49
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
49
50
|
# raw-prompt loop.
|
|
50
51
|
def chat? = api == :openai
|
|
@@ -383,6 +384,21 @@ module Samagotchi
|
|
|
383
384
|
@mutex.synchronize { @cache }
|
|
384
385
|
end
|
|
385
386
|
|
|
387
|
+
# Hosts, their addresses, key variable names and declared models, never
|
|
388
|
+
# the environment: it holds every host's API key (a failing spec prints
|
|
389
|
+
# the registry).
|
|
390
|
+
def inspect
|
|
391
|
+
hosts = @entries.values.map do |entry|
|
|
392
|
+
parts = [entry.openai_base_url]
|
|
393
|
+
parts << "key=#{entry.api_key_env}" if entry.api_key_env
|
|
394
|
+
parts << "models=#{entry.models.values.map(&:id).join(",")}" unless entry.models.empty?
|
|
395
|
+
"#{entry.name}(#{parts.join(" ")})"
|
|
396
|
+
end
|
|
397
|
+
"#<#{self.class.name} #{hosts.join(", ")}>"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
401
|
+
|
|
386
402
|
private
|
|
387
403
|
|
|
388
404
|
# A listing error as text without the host's name (a provider error's
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "installed_gem"
|
|
4
4
|
require_relative "version"
|
|
5
|
+
require_relative "redacted_inspect"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# The newest chi on this machine, which a long-lived process (chi web, an
|
|
@@ -18,6 +19,8 @@ module Samagotchi
|
|
|
18
19
|
# SAMAGOTCHI_INSTALLED_VERSION=<v> (hidden, for smokes and e2e) answers
|
|
19
20
|
# <v> instead.
|
|
20
21
|
class InstalledVersions
|
|
22
|
+
include RedactedInspect
|
|
23
|
+
|
|
21
24
|
ENV_KEY = "SAMAGOTCHI_INSTALLED_VERSION"
|
|
22
25
|
GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
|
|
23
26
|
VERSION_LINE = /^\s*VERSION\s*=\s*["']([^"']+)["']/
|
|
@@ -32,6 +32,7 @@ require_relative "llm_context_notice"
|
|
|
32
32
|
require_relative "answer_display"
|
|
33
33
|
require_relative "llm/model_result"
|
|
34
34
|
require_relative "llm/turn_settings"
|
|
35
|
+
require_relative "llm/stream_drops"
|
|
35
36
|
|
|
36
37
|
module Samagotchi
|
|
37
38
|
# The KernelLoop drives the model ↔ tool interaction cycle.
|
|
@@ -47,6 +48,8 @@ module Samagotchi
|
|
|
47
48
|
# - Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
48
49
|
# - Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
49
50
|
class KernelLoop
|
|
51
|
+
include LLM::StreamDrops
|
|
52
|
+
|
|
50
53
|
# The built-in tool classes (Tools::Builtins registers them).
|
|
51
54
|
TOOLS = Tools::Builtins::CLASSES
|
|
52
55
|
|
|
@@ -113,8 +116,12 @@ module Samagotchi
|
|
|
113
116
|
|
|
114
117
|
# @param tools [Tools::Registry, nil] the tools calls dispatch to (the
|
|
115
118
|
# Engine's; nil: the built-ins alone)
|
|
116
|
-
|
|
119
|
+
# @param sleeper [#call, nil] waits the given seconds before a dropped
|
|
120
|
+
# stream's step is asked again (specs pass one that doesn't sleep)
|
|
121
|
+
def initialize(client: nil, profile: nil, model_name: nil, hooks: nil, reminder_store: nil, model_key: nil, tools: nil,
|
|
122
|
+
sleeper: nil)
|
|
117
123
|
@client = client || Client.new
|
|
124
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
118
125
|
@tools = tools || Tools::Builtins.default
|
|
119
126
|
resolved_model_name = ModelProfile.required_model_name(model_name)
|
|
120
127
|
# The turn's settings (LLM::TurnSettings). Its model name is the
|
|
@@ -349,9 +356,9 @@ module Samagotchi
|
|
|
349
356
|
# Ends with its :generation_completed (and, unless cut, the
|
|
350
357
|
# after_generation hook).
|
|
351
358
|
def generate(turn, request)
|
|
352
|
-
# Each
|
|
353
|
-
# thought or a tool call doesn't
|
|
354
|
-
|
|
359
|
+
# Each ask splits its own stream (run_generation rebuilds the
|
|
360
|
+
# splitter), so one that ended inside a thought or a tool call doesn't
|
|
361
|
+
# leave the next one "inside" it.
|
|
355
362
|
emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
|
|
356
363
|
context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
|
|
357
364
|
price: @turn_settings.price&.to_h)
|
|
@@ -369,14 +376,55 @@ module Samagotchi
|
|
|
369
376
|
uncached = turn.uncached_next
|
|
370
377
|
turn.uncached_next = false
|
|
371
378
|
generation.response = with_generation(turn.cancel_controller) do |generation_controller|
|
|
379
|
+
run_generation(turn, request, generation, generation_controller, buffer_mark, uncached)
|
|
380
|
+
rescue Client::RequestCancelled
|
|
381
|
+
raise unless cut?(generation_controller, turn.cancel_controller)
|
|
382
|
+
|
|
383
|
+
generation.cut = generation_controller.detail || {}
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
|
|
387
|
+
generation
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# One generation's request, asking again on a mid-stream drop (LLM::HTTP
|
|
391
|
+
# raises RetryExhausted once a stream that already showed something
|
|
392
|
+
# fails, where a retry would repeat output) up to MAX_STREAM_DROPS
|
|
393
|
+
# times after the transport's backoff: the partial reply is dropped and
|
|
394
|
+
# the step restarts from the same conversation, as generation_retrying
|
|
395
|
+
# with restarted: true. A cancel (before or during the wait) never
|
|
396
|
+
# retries; a refusal or a first-token timeout fails as before.
|
|
397
|
+
def run_generation(turn, request, generation, generation_controller, buffer_mark, uncached)
|
|
398
|
+
# The empty-retry sampling is taken once, before the ask-again loop:
|
|
399
|
+
# a drop's restart must still carry the nudge's 0.6 (as ChatLoop does
|
|
400
|
+
# per step), not lose it when request_sampling is asked again.
|
|
401
|
+
sampling = turn.empty_retry.request_sampling(@turn_settings.sampling)
|
|
402
|
+
count = LLM::StreamDrops::DropCount.new(0, 0)
|
|
403
|
+
begin
|
|
404
|
+
# Each ask splits its own stream: a drop mid tool call (or mid
|
|
405
|
+
# thinking) leaves the splitter inside that block, so rebuilding it
|
|
406
|
+
# keeps the restarted stream's prose in the right lane.
|
|
407
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
408
|
+
# Each ask starts with streamed_any = false, but once any chunk of
|
|
409
|
+
# this ask is shown it stays shown: a held-back partial marker after
|
|
410
|
+
# real text must not clear it, or the drop below won't ask again.
|
|
411
|
+
streamed_any = false
|
|
372
412
|
request_generation(
|
|
373
413
|
request.prompt,
|
|
374
414
|
generation_controller: generation_controller,
|
|
375
415
|
cancel_controller: turn.cancel_controller,
|
|
376
416
|
model_name: turn.model_name,
|
|
377
|
-
sampling:
|
|
417
|
+
sampling: sampling,
|
|
378
418
|
cache_prompt: !uncached,
|
|
379
|
-
on_chunk:
|
|
419
|
+
on_chunk: lambda { |chunk|
|
|
420
|
+
# Shown the way ChatLoop counts it: anything that split into
|
|
421
|
+
# text or thinking was shown (a tool-call delta counts too, even
|
|
422
|
+
# though its bytes are dropped from both lanes). stream_chunk runs
|
|
423
|
+
# for every chunk (it splits the stream and emits the chunk event);
|
|
424
|
+
# streamed_any is sticky — once shown it stays shown, so a
|
|
425
|
+
# held-back partial marker after real text doesn't clear it.
|
|
426
|
+
streamed_any = true if stream_chunk(turn, generation, stream_splitter, chunk)
|
|
427
|
+
},
|
|
380
428
|
on_retry: lambda { |retry_event|
|
|
381
429
|
# The retry streams from the start: its counts replace these.
|
|
382
430
|
generation.usage = nil
|
|
@@ -388,17 +436,54 @@ module Samagotchi
|
|
|
388
436
|
},
|
|
389
437
|
images: request.images
|
|
390
438
|
)
|
|
391
|
-
rescue
|
|
392
|
-
|
|
439
|
+
rescue LLM::RetryExhausted => e
|
|
440
|
+
# A cancel that landed as the stream drops ends the turn canceled,
|
|
441
|
+
# not failed, whatever the drop's count.
|
|
442
|
+
cancelled = [turn.cancel_controller, generation_controller].compact.find(&:cancelled?)
|
|
443
|
+
raise Client::RequestCancelled, cancelled.reason if cancelled
|
|
444
|
+
|
|
445
|
+
count.requests += e.attempts.to_i
|
|
446
|
+
limit = stream_drop_limit(LLM::StreamDrops::MAX_STREAM_DROPS)
|
|
447
|
+
if count.drops >= limit || !streamed_any
|
|
448
|
+
raise if count.requests == e.attempts.to_i
|
|
449
|
+
|
|
450
|
+
raise LLM::RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
|
|
451
|
+
end
|
|
393
452
|
|
|
394
|
-
|
|
395
|
-
|
|
453
|
+
count.drops += 1
|
|
454
|
+
attempt = count.drops
|
|
455
|
+
# restarted: the step's streamed chunks so far are void (the UIs and
|
|
456
|
+
# the Bridge's TurnAccumulator drop them); the same prompt goes
|
|
457
|
+
# again from where the step started, and the failed attempt's
|
|
458
|
+
# counts (mirroring the transport-retry reset) don't linger onto it.
|
|
459
|
+
turn.buffer.slice!(buffer_mark..)
|
|
460
|
+
turn.streamed_thinking = +""
|
|
461
|
+
generation.usage = nil
|
|
462
|
+
generation.cache = nil
|
|
463
|
+
generation.served_model = nil
|
|
464
|
+
generation.finish_reason = nil
|
|
465
|
+
generation.streamed_thinking = 0
|
|
466
|
+
delay = generation_retry_delay(attempt)
|
|
467
|
+
emit(turn, type: :generation_retrying, iteration: turn.iteration, attempt: attempt, max_retries: limit,
|
|
468
|
+
next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
|
|
469
|
+
wait_retry(delay, turn.cancel_controller, generation_controller)
|
|
470
|
+
retry
|
|
471
|
+
rescue LLM::ProviderError => e
|
|
472
|
+
# Another failure (a 503, a refusal) after earlier requests this
|
|
473
|
+
# step: its count takes theirs in.
|
|
474
|
+
count.requests += e.attempts.to_i
|
|
475
|
+
e.attempts = count.requests
|
|
476
|
+
raise
|
|
396
477
|
end
|
|
397
|
-
generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
|
|
398
|
-
generation
|
|
399
478
|
end
|
|
400
479
|
|
|
480
|
+
# Whether the chunk showed anything: a delta that split into text or
|
|
481
|
+
# thinking was shown, and a tool-call delta too (its bytes are dropped
|
|
482
|
+
# from both lanes, but it still counts). run_generation reads this to
|
|
483
|
+
# decide whether a dropped stream actually streamed its partial reply.
|
|
401
484
|
def stream_chunk(turn, generation, stream_splitter, chunk)
|
|
485
|
+
split = stream_splitter.feed(chunk[:content])
|
|
486
|
+
shown = !split[:text].to_s.empty? || !split[:thinking].to_s.empty? || split[:tool]
|
|
402
487
|
generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
|
|
403
488
|
note_cache_counts(generation, chunk[:payload])
|
|
404
489
|
# llama.cpp names the loaded model in the stream's last payload.
|
|
@@ -407,18 +492,16 @@ module Samagotchi
|
|
|
407
492
|
slot = chunk[:payload]["id_slot"] if chunk[:payload].is_a?(Hash)
|
|
408
493
|
@last_slot = slot if slot.is_a?(Integer)
|
|
409
494
|
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
410
|
-
split = stream_splitter.feed(chunk[:content])
|
|
411
495
|
turn.buffer << split[:text]
|
|
412
496
|
turn.streamed_thinking << split[:thinking].to_s
|
|
413
497
|
generation.streamed_thinking += split[:thinking].to_s.length
|
|
414
|
-
return unless turn.on_stream_event
|
|
415
|
-
|
|
416
498
|
event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
417
499
|
thinking: split[:thinking], payload: event_payload(chunk[:payload]) }
|
|
418
500
|
# A chunk of a tool call (its bytes are dropped from both lanes): a
|
|
419
501
|
# steer doesn't cut this generation (Engine#cut_for_steer).
|
|
420
502
|
event[:tool_call] = true if split[:tool]
|
|
421
|
-
emit(turn, **event)
|
|
503
|
+
emit(turn, **event) if turn.on_stream_event
|
|
504
|
+
shown
|
|
422
505
|
end
|
|
423
506
|
|
|
424
507
|
# The payload a :generation_chunk event carries: llama.cpp's last chunk
|