samagotchi 0.24.0 → 0.26.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 +80 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +4 -3
- data/docs/configuration.md +22 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +478 -0
- data/docs/internals/prompt-caching.md +120 -0
- data/docs/plugins.md +18 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cancellation_controller.rb +8 -0
- data/lib/samagotchi/client.rb +53 -3
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +14 -4
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +73 -11
- data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/kernel_loop.rb +60 -6
- data/lib/samagotchi/llm/chat_loop.rb +18 -4
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/http.rb +5 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt_warmup.rb +95 -0
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/thinking_tails.rb +104 -0
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_view.rb +40 -4
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_note.rb +26 -2
- data/lib/samagotchi/turn_state.rb +6 -0
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +74 -3
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +45 -13
- data/lib/samagotchi/web/public/stage_model.js +5 -1
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/turn_view.js +3 -3
- data/lib/samagotchi/worker.rb +3 -0
- metadata +7 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "session_commands"
|
|
|
22
22
|
require_relative "plugin/loader"
|
|
23
23
|
require_relative "log"
|
|
24
24
|
require_relative "log_subscriber"
|
|
25
|
+
require_relative "thinking_tails"
|
|
25
26
|
require_relative "client"
|
|
26
27
|
require_relative "host_registry"
|
|
27
28
|
require_relative "llm/backend"
|
|
@@ -63,6 +64,7 @@ require_relative "question_desk"
|
|
|
63
64
|
require_relative "relay_desk"
|
|
64
65
|
require_relative "guardrail_wiring"
|
|
65
66
|
require_relative "plugin_tasks"
|
|
67
|
+
require_relative "prompt_warmup"
|
|
66
68
|
|
|
67
69
|
module Samagotchi
|
|
68
70
|
# Engine owns the core agent logic: system prompt construction, tool
|
|
@@ -172,8 +174,9 @@ module Samagotchi
|
|
|
172
174
|
@hooks = load_hooks_from_config
|
|
173
175
|
load_hooks_from_bundles
|
|
174
176
|
# The tools this session offers (the prompts' declarations and the
|
|
175
|
-
# kernel's dispatch): the built-ins, per Engine.
|
|
176
|
-
|
|
177
|
+
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
178
|
+
# off: execute offers no description parameter.
|
|
179
|
+
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
|
|
177
180
|
if @scratch
|
|
178
181
|
# A scratch session's children would outlive it, and so would a
|
|
179
182
|
# note it left in a peer; its memories would too (write and edit
|
|
@@ -240,6 +243,9 @@ module Samagotchi
|
|
|
240
243
|
# list_sessions and send_note speak for whichever session runs now.
|
|
241
244
|
@kernel.peers = PeerView.new(self)
|
|
242
245
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
246
|
+
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
247
|
+
@prompt_warmup = PromptWarmup.new
|
|
248
|
+
@kernel.warmup = @prompt_warmup
|
|
243
249
|
# What a hook can do beyond reading its event (event[:notify],
|
|
244
250
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
245
251
|
@hooks.runtime = hook_runtime
|
|
@@ -282,6 +288,10 @@ module Samagotchi
|
|
|
282
288
|
@session_observer.subscribe(observer: @metrics)
|
|
283
289
|
# And the event trail in the debug log (`turn` records).
|
|
284
290
|
@session_observer.subscribe(observer: LogSubscriber.new(session_id: -> { @session&.id }))
|
|
291
|
+
# The thinking of cut and capped generations, kept in the session dir.
|
|
292
|
+
@session_observer.subscribe(observer: ThinkingTails.new(session_dir: lambda {
|
|
293
|
+
@session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
|
|
294
|
+
}))
|
|
285
295
|
end
|
|
286
296
|
|
|
287
297
|
# A monotonically-increasing clock (wall clock can jump backwards; the idle
|
|
@@ -1609,6 +1619,7 @@ module Samagotchi
|
|
|
1609
1619
|
pending_input: pending_input)
|
|
1610
1620
|
publish_used_memories(session, on_event)
|
|
1611
1621
|
complete_turn(turn, result)
|
|
1622
|
+
warm_up_next_turn(turn, result)
|
|
1612
1623
|
result
|
|
1613
1624
|
rescue Interrupt
|
|
1614
1625
|
# After the end event (in the after_turn/session_end hooks) the
|
|
@@ -1745,13 +1756,7 @@ module Samagotchi
|
|
|
1745
1756
|
# Who stopped a turn a hook stopped or cut (the controller's detail: the
|
|
1746
1757
|
# bundle, or the hook's label), for the UIs' "stopped by loop-guard";
|
|
1747
1758
|
# nil for any other cancel.
|
|
1748
|
-
def cancelled_by(turn)
|
|
1749
|
-
return nil unless turn.controller.reason == :hook
|
|
1750
|
-
|
|
1751
|
-
detail = turn.controller.detail
|
|
1752
|
-
by = detail[:by].to_s if detail.is_a?(Hash)
|
|
1753
|
-
by unless by.nil? || by.empty?
|
|
1754
|
-
end
|
|
1759
|
+
def cancelled_by(turn) = turn.controller.stopped_by
|
|
1755
1760
|
private :cancelled_by
|
|
1756
1761
|
|
|
1757
1762
|
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
@@ -1769,6 +1774,50 @@ module Samagotchi
|
|
|
1769
1774
|
end
|
|
1770
1775
|
private :turn_settings
|
|
1771
1776
|
|
|
1777
|
+
# @return [PromptWarmup] the turn-end warm-up (specs, measurements)
|
|
1778
|
+
attr_reader :prompt_warmup
|
|
1779
|
+
|
|
1780
|
+
# A caller's check that the next turn starts at once (input queued for
|
|
1781
|
+
# it: a worker's inbox) or that none follows (a one-shot run): no
|
|
1782
|
+
# warm-up then. nil: neither.
|
|
1783
|
+
attr_writer :next_turn_waiting
|
|
1784
|
+
|
|
1785
|
+
# After a completed turn, prefill the next turn's prompt up to its user
|
|
1786
|
+
# message while the user reads (PromptWarmup, cache.warmup). Only on a
|
|
1787
|
+
# local llama.cpp host on the native loop, and not when the next turn's
|
|
1788
|
+
# start differs anyway or starts at once: a reminder due, a continue
|
|
1789
|
+
# offer (the turn ran out of steps), a carried steer or queued input.
|
|
1790
|
+
# Never fails the turn.
|
|
1791
|
+
def warm_up_next_turn(turn, result)
|
|
1792
|
+
return if result.canceled? || result.resumable?
|
|
1793
|
+
|
|
1794
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
1795
|
+
client = target.client
|
|
1796
|
+
return unless warmup_target?(target, client)
|
|
1797
|
+
return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
|
|
1798
|
+
|
|
1799
|
+
messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(target) })
|
|
1800
|
+
prompt, images = @kernel.warmup_prompt(messages)
|
|
1801
|
+
return unless prompt
|
|
1802
|
+
|
|
1803
|
+
@prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
|
|
1804
|
+
slot: @kernel.last_slot, images: images)
|
|
1805
|
+
rescue StandardError => e
|
|
1806
|
+
Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1807
|
+
end
|
|
1808
|
+
private :warm_up_next_turn
|
|
1809
|
+
|
|
1810
|
+
# Never a paid or remote host (cache.warmup auto means local only), nor
|
|
1811
|
+
# the chat loop (Splash keeps its own cache; llama.cpp's chat path is
|
|
1812
|
+
# not measured), nor a non-llama.cpp transport.
|
|
1813
|
+
def warmup_target?(target, client)
|
|
1814
|
+
return false if target.entry.chat? || target.entry.remote?
|
|
1815
|
+
return false unless client.respond_to?(:warm_up) && client.respond_to?(:transport) && client.transport.native?
|
|
1816
|
+
|
|
1817
|
+
PromptWarmup.enabled?
|
|
1818
|
+
end
|
|
1819
|
+
private :warmup_target?
|
|
1820
|
+
|
|
1772
1821
|
# The messages the turn sends: the history under the system head, due
|
|
1773
1822
|
# reminders, the prompt. turn.messages is set first and grown in place,
|
|
1774
1823
|
# so a Ctrl-C or a failure meanwhile keeps what is there.
|
|
@@ -2039,12 +2088,23 @@ module Samagotchi
|
|
|
2039
2088
|
nil
|
|
2040
2089
|
end
|
|
2041
2090
|
|
|
2091
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
2092
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
2093
|
+
# with thinking off), true once a real thought came.
|
|
2094
|
+
def note_generation_thought(thinking)
|
|
2095
|
+
return if thinking.empty?
|
|
2096
|
+
|
|
2097
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
2098
|
+
end
|
|
2099
|
+
|
|
2042
2100
|
# Thinking off, and the model thought anyway: logged each time, said
|
|
2043
|
-
# once per session and host.
|
|
2101
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
2102
|
+
# thought.
|
|
2044
2103
|
def check_thinking_honoured(event)
|
|
2045
2104
|
level, target = @turn_thinking
|
|
2046
2105
|
chars = event[:thinking_chars].to_i
|
|
2047
2106
|
return unless level == :off && chars.positive? && target
|
|
2107
|
+
return if @generation_thought == false
|
|
2048
2108
|
|
|
2049
2109
|
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
2050
2110
|
thinking_notice_once(:not_honoured, target, :warn,
|
|
@@ -2184,7 +2244,7 @@ module Samagotchi
|
|
|
2184
2244
|
return @native_backend unless target.entry.chat?
|
|
2185
2245
|
|
|
2186
2246
|
@chat_backend_mutex.synchronize do
|
|
2187
|
-
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel)
|
|
2247
|
+
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
|
|
2188
2248
|
@chat_backend.adapter = @host_registry.adapter_for(target.entry)
|
|
2189
2249
|
@chat_backend.session_id = session&.id
|
|
2190
2250
|
@chat_backend
|
|
@@ -2406,6 +2466,7 @@ module Samagotchi
|
|
|
2406
2466
|
case event[:type]
|
|
2407
2467
|
when :generation_started
|
|
2408
2468
|
@generation_phase.started!
|
|
2469
|
+
@generation_thought = nil
|
|
2409
2470
|
watch&.started(event[:iteration])
|
|
2410
2471
|
when :generation_chunk
|
|
2411
2472
|
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
@@ -2413,6 +2474,7 @@ module Samagotchi
|
|
|
2413
2474
|
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2414
2475
|
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2415
2476
|
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2477
|
+
note_generation_thought(progress[:thinking])
|
|
2416
2478
|
when :generation_retrying then @generation_phase.retrying!
|
|
2417
2479
|
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2418
2480
|
end
|
|
@@ -33,6 +33,15 @@ module Samagotchi
|
|
|
33
33
|
Lexer.new(text).tokens
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# lex's tokens, each with the range of +text+ it was read from
|
|
37
|
+
# appended ([:word, "x", specials, 0...1] / [:op, "&&", 2...4]), for a
|
|
38
|
+
# display that slices the original text. A heredoc body's range is
|
|
39
|
+
# the body and its terminator line. lex itself is unchanged by this.
|
|
40
|
+
def self.lex_with_spans(text)
|
|
41
|
+
lexer = Lexer.new(text)
|
|
42
|
+
lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
# The token stream split at operators into word lists; ( and ) become
|
|
37
46
|
# :open / :close markers.
|
|
38
47
|
def self.simple_commands(tokens)
|
|
@@ -57,18 +66,34 @@ module Samagotchi
|
|
|
57
66
|
@s = text
|
|
58
67
|
@i = 0
|
|
59
68
|
@tokens = []
|
|
69
|
+
@spans = []
|
|
70
|
+
@start = 0
|
|
60
71
|
@word = +""
|
|
61
72
|
@specials = +""
|
|
62
73
|
@in_word = false
|
|
63
74
|
@heredocs = []
|
|
75
|
+
@heredoc_spans = []
|
|
76
|
+
@unterminated = false
|
|
64
77
|
end
|
|
65
78
|
|
|
66
79
|
def tokens
|
|
67
80
|
step while @i < @s.size
|
|
68
81
|
flush
|
|
82
|
+
@unterminated = true unless @heredocs.empty?
|
|
69
83
|
@tokens
|
|
70
84
|
end
|
|
71
85
|
|
|
86
|
+
# Each token's range in the text, in step with #tokens (after it).
|
|
87
|
+
attr_reader :spans
|
|
88
|
+
|
|
89
|
+
# The heredocs read ({tag:, span:}, the body and its terminator
|
|
90
|
+
# line), after #tokens.
|
|
91
|
+
attr_reader :heredoc_spans
|
|
92
|
+
|
|
93
|
+
# Whether a quote, $(…), backtick or heredoc was left open (after
|
|
94
|
+
# #tokens): the lexer's best effort, not the shell's reading.
|
|
95
|
+
def unterminated? = @unterminated
|
|
96
|
+
|
|
72
97
|
private
|
|
73
98
|
|
|
74
99
|
def step
|
|
@@ -87,6 +112,7 @@ module Samagotchi
|
|
|
87
112
|
end
|
|
88
113
|
|
|
89
114
|
def add(text, advance)
|
|
115
|
+
@start = @i unless @in_word
|
|
90
116
|
@word << text
|
|
91
117
|
@in_word = true
|
|
92
118
|
@i += advance
|
|
@@ -99,7 +125,10 @@ module Samagotchi
|
|
|
99
125
|
end
|
|
100
126
|
|
|
101
127
|
def flush
|
|
102
|
-
|
|
128
|
+
if @in_word
|
|
129
|
+
@tokens << [:word, @word.dup, @specials.dup]
|
|
130
|
+
@spans << (@start...[@i, @s.size].min)
|
|
131
|
+
end
|
|
103
132
|
@word.clear
|
|
104
133
|
@specials.clear
|
|
105
134
|
@in_word = false
|
|
@@ -107,11 +136,14 @@ module Samagotchi
|
|
|
107
136
|
end
|
|
108
137
|
|
|
109
138
|
def single_quote
|
|
110
|
-
close = @s.index("'", @i + 1)
|
|
139
|
+
close = @s.index("'", @i + 1)
|
|
140
|
+
@unterminated ||= close.nil?
|
|
141
|
+
close ||= @s.size
|
|
111
142
|
add(@s[(@i + 1)...close].to_s, close + 1 - @i)
|
|
112
143
|
end
|
|
113
144
|
|
|
114
145
|
def double_quote
|
|
146
|
+
@start = @i unless @in_word
|
|
115
147
|
@i += 1
|
|
116
148
|
@in_word = true
|
|
117
149
|
while @i < @s.size && @s[@i] != '"'
|
|
@@ -126,11 +158,14 @@ module Samagotchi
|
|
|
126
158
|
@i += 1
|
|
127
159
|
end
|
|
128
160
|
end
|
|
161
|
+
@unterminated = true if @i >= @s.size
|
|
129
162
|
@i += 1
|
|
130
163
|
end
|
|
131
164
|
|
|
132
165
|
def backtick
|
|
133
|
-
close = @s.index("`", @i + 1)
|
|
166
|
+
close = @s.index("`", @i + 1)
|
|
167
|
+
@unterminated ||= close.nil?
|
|
168
|
+
close ||= @s.size
|
|
134
169
|
add(SUBST, close + 1 - @i)
|
|
135
170
|
end
|
|
136
171
|
|
|
@@ -143,6 +178,7 @@ module Samagotchi
|
|
|
143
178
|
j += 1
|
|
144
179
|
break if depth.zero? || j >= @s.size
|
|
145
180
|
end
|
|
181
|
+
@unterminated = true unless depth.zero?
|
|
146
182
|
add(SUBST, j - @i)
|
|
147
183
|
end
|
|
148
184
|
|
|
@@ -170,6 +206,7 @@ module Samagotchi
|
|
|
170
206
|
else
|
|
171
207
|
flush
|
|
172
208
|
@tokens << [:op, op]
|
|
209
|
+
@spans << (@i...(@i + op.size))
|
|
173
210
|
@i += op.size
|
|
174
211
|
bodies if op == "\n" && !@heredocs.empty?
|
|
175
212
|
end
|
|
@@ -223,16 +260,23 @@ module Samagotchi
|
|
|
223
260
|
heredocs = @heredocs
|
|
224
261
|
@heredocs = []
|
|
225
262
|
pos = @i
|
|
263
|
+
start = pos
|
|
226
264
|
read = []
|
|
227
265
|
heredocs.each do |doc|
|
|
228
266
|
body, pos = body(doc, pos)
|
|
229
267
|
break unless body
|
|
230
268
|
|
|
231
|
-
read << [doc, body]
|
|
269
|
+
read << [doc, body, start...(@s[pos - 1] == "\n" ? pos - 1 : pos)]
|
|
270
|
+
start = pos
|
|
232
271
|
end
|
|
272
|
+
@unterminated ||= read.size < heredocs.size
|
|
233
273
|
return if read.size < heredocs.size
|
|
234
274
|
|
|
235
|
-
read.reverse_each
|
|
275
|
+
read.reverse_each do |doc, body, span|
|
|
276
|
+
@tokens.insert(doc[:at], [:word, body_word(doc, body), ""])
|
|
277
|
+
@spans.insert(doc[:at], span)
|
|
278
|
+
end
|
|
279
|
+
read.each { |doc, _body, span| @heredoc_spans << { tag: doc[:tag], span: span } }
|
|
236
280
|
@i = pos
|
|
237
281
|
end
|
|
238
282
|
|
|
@@ -93,7 +93,8 @@ module Samagotchi
|
|
|
93
93
|
# @raise [SummarizeError] any failure but a cancel
|
|
94
94
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
95
95
|
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
|
|
96
|
-
content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false
|
|
96
|
+
content, served = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
97
|
+
kind: "ask")
|
|
97
98
|
Summary.new(text: content.to_s.strip, model: served)
|
|
98
99
|
rescue SummarizeError, LLM::RequestCancelled
|
|
99
100
|
raise
|
|
@@ -109,7 +110,7 @@ module Samagotchi
|
|
|
109
110
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
110
111
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
111
112
|
# or all of it, with "…".
|
|
112
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
|
|
113
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
|
|
113
114
|
response = begin
|
|
114
115
|
request(messages, max_tokens, cancel_controller)
|
|
115
116
|
rescue LLM::BadRequest => e
|
|
@@ -121,6 +122,7 @@ module Samagotchi
|
|
|
121
122
|
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
122
123
|
request(messages, max_tokens, cancel_controller)
|
|
123
124
|
end
|
|
125
|
+
log_usage(response, kind)
|
|
124
126
|
content = response.text
|
|
125
127
|
reasoning = response.reasoning
|
|
126
128
|
raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
|
|
@@ -141,6 +143,16 @@ module Samagotchi
|
|
|
141
143
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
142
144
|
end
|
|
143
145
|
|
|
146
|
+
# The request's prompt-cache counts (a side request on the session's
|
|
147
|
+
# host can evict the session's cached prompt there).
|
|
148
|
+
def log_usage(response, kind)
|
|
149
|
+
fields = response.usage&.cache_fields || {}
|
|
150
|
+
return if fields.empty?
|
|
151
|
+
|
|
152
|
+
Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
|
|
153
|
+
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
154
|
+
end
|
|
155
|
+
|
|
144
156
|
def request(messages, max_tokens, cancel_controller)
|
|
145
157
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
146
158
|
options: { max_tokens: max_tokens, **thinking_fields })
|
|
@@ -8,6 +8,7 @@ require_relative "context_status"
|
|
|
8
8
|
require_relative "context_window"
|
|
9
9
|
require_relative "prompt"
|
|
10
10
|
require_relative "prompt_literal_guard"
|
|
11
|
+
require_relative "token_usage"
|
|
11
12
|
require_relative "client"
|
|
12
13
|
require_relative "llm/errors"
|
|
13
14
|
require_relative "log"
|
|
@@ -155,6 +156,13 @@ module Samagotchi
|
|
|
155
156
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
156
157
|
# list_sessions and send_note speak for; nil outside a session.
|
|
157
158
|
attr_accessor :peers
|
|
159
|
+
# The Engine's PromptWarmup: a request asks it for a slot pin
|
|
160
|
+
# (#take_pin) while the turn-end warm-up still runs; nil: never pinned.
|
|
161
|
+
attr_accessor :warmup
|
|
162
|
+
# @return [Integer, nil] the llama.cpp slot (`id_slot`) the last
|
|
163
|
+
# request of the last run streamed from; nil when the server named
|
|
164
|
+
# none (other transports) or no request was made
|
|
165
|
+
attr_reader :last_slot
|
|
158
166
|
|
|
159
167
|
# Run the conversation loop and return the final model response plus
|
|
160
168
|
# resumable conversation state when execution stops at max_iterations.
|
|
@@ -176,6 +184,7 @@ module Samagotchi
|
|
|
176
184
|
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
177
185
|
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
178
186
|
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
187
|
+
@last_slot = nil
|
|
179
188
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
180
189
|
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
|
|
181
190
|
|
|
@@ -185,7 +194,8 @@ module Samagotchi
|
|
|
185
194
|
return outcome if outcome.is_a?(LLM::ModelResult)
|
|
186
195
|
break if outcome == :answer
|
|
187
196
|
rescue Client::RequestCancelled => e
|
|
188
|
-
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason
|
|
197
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: e.reason,
|
|
198
|
+
stopped_by: turn.cancel_controller&.stopped_by)
|
|
189
199
|
return cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: e.reason,
|
|
190
200
|
partial_assistant_text: turn.buffer)
|
|
191
201
|
end
|
|
@@ -238,8 +248,10 @@ module Samagotchi
|
|
|
238
248
|
# One generation: the response (nil when cut), the cut's detail, this
|
|
239
249
|
# generation's own server counts, the model the server named, the
|
|
240
250
|
# thinking it streamed, and why it stopped (the transport's finish
|
|
241
|
-
# reason, Client::Transport#finish_reason_from; nil when not named)
|
|
242
|
-
|
|
251
|
+
# reason, Client::Transport#finish_reason_from; nil when not named),
|
|
252
|
+
# and its prompt-cache counts (the largest seen: they come cumulative).
|
|
253
|
+
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
|
|
254
|
+
keyword_init: true)
|
|
243
255
|
private_constant :Turn, :Request, :Generation
|
|
244
256
|
|
|
245
257
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
@@ -328,6 +340,7 @@ module Samagotchi
|
|
|
328
340
|
on_retry: lambda { |retry_event|
|
|
329
341
|
# The retry streams from the start: its counts replace these.
|
|
330
342
|
generation.usage = nil
|
|
343
|
+
generation.cache = nil
|
|
331
344
|
generation.streamed_thinking = 0
|
|
332
345
|
generation.finish_reason = nil
|
|
333
346
|
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
@@ -346,9 +359,12 @@ module Samagotchi
|
|
|
346
359
|
|
|
347
360
|
def stream_chunk(turn, generation, stream_splitter, chunk)
|
|
348
361
|
generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
|
|
362
|
+
note_cache_counts(generation, chunk[:payload])
|
|
349
363
|
# llama.cpp names the loaded model in the stream's last payload.
|
|
350
364
|
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
351
365
|
generation.served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
366
|
+
slot = chunk[:payload]["id_slot"] if chunk[:payload].is_a?(Hash)
|
|
367
|
+
@last_slot = slot if slot.is_a?(Integer)
|
|
352
368
|
generation.finish_reason = chunk[:finish_reason] if chunk[:finish_reason]
|
|
353
369
|
split = stream_splitter.feed(chunk[:content])
|
|
354
370
|
turn.buffer << split[:text]
|
|
@@ -363,6 +379,19 @@ module Samagotchi
|
|
|
363
379
|
emit(turn, **event)
|
|
364
380
|
end
|
|
365
381
|
|
|
382
|
+
# The payload's prompt-cache counts, kept as the largest seen.
|
|
383
|
+
def note_cache_counts(generation, payload)
|
|
384
|
+
counts = TokenUsage.from_payload(payload)
|
|
385
|
+
return unless counts
|
|
386
|
+
|
|
387
|
+
cache = generation.cache ||= { prompt: 0, cached: 0, cache_write: 0 }
|
|
388
|
+
cache[:prompt] = [cache[:prompt], counts.prompt_tokens.to_i].max
|
|
389
|
+
cache[:cached] = [cache[:cached], counts.cached_tokens.to_i].max
|
|
390
|
+
cache[:cache_write] = [cache[:cache_write], counts.cache_write_tokens.to_i].max
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
|
|
394
|
+
|
|
366
395
|
# The cut stream's visible text goes with it: the buffer as it was
|
|
367
396
|
# before (the next generation gets a fresh splitter).
|
|
368
397
|
def cut_generation(turn, generation, buffer_mark)
|
|
@@ -380,7 +409,7 @@ module Samagotchi
|
|
|
380
409
|
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
381
410
|
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
382
411
|
served_model: generation.served_model, requested_model: turn.model_name,
|
|
383
|
-
finish_reason: generation.finish_reason)
|
|
412
|
+
finish_reason: generation.finish_reason, **cache_fields(generation.cache))
|
|
384
413
|
dump_log("response", generation.response, iteration: turn.iteration)
|
|
385
414
|
# The after_generation hook (after the model returns, before the tool
|
|
386
415
|
# parse), with a read-only copy of the conversation as sent.
|
|
@@ -415,7 +444,7 @@ module Samagotchi
|
|
|
415
444
|
end
|
|
416
445
|
turn.empty_retry.drop_nudge!(turn.conversation)
|
|
417
446
|
turn.cancel_controller.cancel!(:hook, cut)
|
|
418
|
-
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook)
|
|
447
|
+
emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut[:by])
|
|
419
448
|
cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
|
|
420
449
|
end
|
|
421
450
|
|
|
@@ -548,7 +577,11 @@ module Samagotchi
|
|
|
548
577
|
# One request, cancelled by +generation_controller+ (the turn's child)
|
|
549
578
|
# when there is one.
|
|
550
579
|
def request_generation(prompt, generation_controller:, cancel_controller:, **)
|
|
551
|
-
|
|
580
|
+
kwargs = complete_kwargs(cancel_controller: generation_controller || cancel_controller, **)
|
|
581
|
+
# Behind a turn-end warm-up still running on its slot (PromptWarmup).
|
|
582
|
+
slot = @warmup&.take_pin(@client)
|
|
583
|
+
kwargs[:slot] = slot if slot
|
|
584
|
+
@client.complete(prompt, **kwargs)
|
|
552
585
|
end
|
|
553
586
|
|
|
554
587
|
# Yields the turn controller's child for one generation (nil without a
|
|
@@ -626,6 +659,22 @@ module Samagotchi
|
|
|
626
659
|
|
|
627
660
|
public
|
|
628
661
|
|
|
662
|
+
# The next turn's prompt up to where its user message starts, for the
|
|
663
|
+
# turn-end warm-up (PromptWarmup): +messages+ as the next turn sends
|
|
664
|
+
# them before its prompt (the history under the system head), formatted
|
|
665
|
+
# as #run formats them, with a stand-in user message cut off at its
|
|
666
|
+
# opener. Returns [prompt, images], or nil when the cut isn't found.
|
|
667
|
+
def warmup_prompt(messages)
|
|
668
|
+
conversation = prepare_conversation(messages) << { role: "user", content: WARMUP_CUT }
|
|
669
|
+
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @turn_settings.vision)
|
|
670
|
+
head = prompt[0, prompt.index(WARMUP_CUT) || 0]
|
|
671
|
+
cut = head.rindex(user_opener)
|
|
672
|
+
cut&.positive? ? [head[0, cut], images] : nil
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
WARMUP_CUT = "chi-warmup-cut"
|
|
676
|
+
private_constant :WARMUP_CUT
|
|
677
|
+
|
|
629
678
|
# Public wrapper so other loops (e.g. the chat loop) can strip
|
|
630
679
|
# per-profile thought blocks from finished model text without duplicating the
|
|
631
680
|
# Gemma 4 / Qwen 3.6 logic. Mirrors the native loop's "strip before deciding
|
|
@@ -661,6 +710,11 @@ module Samagotchi
|
|
|
661
710
|
parser.strip_thought(text)
|
|
662
711
|
end
|
|
663
712
|
|
|
713
|
+
# What opens a user turn in the profile's format.
|
|
714
|
+
def user_opener
|
|
715
|
+
@profile.uses_role_prefixes? ? @profile.user_prefix : "#{@profile.turn_start}user\n"
|
|
716
|
+
end
|
|
717
|
+
|
|
664
718
|
def sanitize_history(messages)
|
|
665
719
|
messages.map do |m|
|
|
666
720
|
if m[:role] == "model"
|
|
@@ -50,9 +50,12 @@ module Samagotchi
|
|
|
50
50
|
# @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
|
|
51
51
|
# @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
|
|
52
52
|
# per turn for the effective model's host
|
|
53
|
-
|
|
53
|
+
# @param stable_length [#call, nil] text → the length of the system
|
|
54
|
+
# prompt's part every session shares (SystemPrompt#stable_length), or nil
|
|
55
|
+
def initialize(kernel:, adapter: nil, stable_length: nil)
|
|
54
56
|
@kernel = kernel
|
|
55
57
|
@adapter = adapter
|
|
58
|
+
@stable_length = stable_length
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def provider = :chat
|
|
@@ -105,7 +108,7 @@ module Samagotchi
|
|
|
105
108
|
items = plan.items(entry, index)
|
|
106
109
|
case entry[:role].to_s
|
|
107
110
|
when "system"
|
|
108
|
-
wire <<
|
|
111
|
+
wire << system_message(entry, index)
|
|
109
112
|
when "user"
|
|
110
113
|
# A steer or merged input (always a String) goes with its header.
|
|
111
114
|
content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
|
|
@@ -132,6 +135,16 @@ module Samagotchi
|
|
|
132
135
|
wire
|
|
133
136
|
end
|
|
134
137
|
|
|
138
|
+
# The head system prompt says where its stable part ends
|
|
139
|
+
# (`cache_split:`, a character offset): the adapter puts a remote
|
|
140
|
+
# Claude model's cache breakpoint there (PromptCache) and drops it
|
|
141
|
+
# for every other request.
|
|
142
|
+
def system_message(entry, index)
|
|
143
|
+
text = entry[:content].to_s
|
|
144
|
+
split = index.zero? && @stable_length&.call(text)
|
|
145
|
+
split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
|
|
146
|
+
end
|
|
147
|
+
|
|
135
148
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
136
149
|
|
|
137
150
|
# The turn's VisionContext (the Engine sets the kernel's
|
|
@@ -448,7 +461,8 @@ module Samagotchi
|
|
|
448
461
|
record_context_status(response.usage, window)
|
|
449
462
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
450
463
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
451
|
-
served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason
|
|
464
|
+
served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason,
|
|
465
|
+
**(response.usage&.cache_fields || {}))
|
|
452
466
|
dump_response(response, iteration)
|
|
453
467
|
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
454
468
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
@@ -581,7 +595,7 @@ module Samagotchi
|
|
|
581
595
|
# The text streamed before the cancel stays, marked [interrupted],
|
|
582
596
|
# as the native loop's salvage does.
|
|
583
597
|
def canceled(iteration, reason, partial = "")
|
|
584
|
-
emit(type: :generation_cancelled, iteration: iteration, reason: reason)
|
|
598
|
+
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
585
599
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
586
600
|
@conversation << { role: "model", content: "#{visible}\n[interrupted]", interrupted: true } unless visible.empty?
|
|
587
601
|
conversation = @loop.plain(@conversation)
|
|
@@ -25,6 +25,9 @@ module Samagotchi
|
|
|
25
25
|
# CreditsHeld 402 "… in-flight requests": credit reserved by other
|
|
26
26
|
# requests; a RateLimited, retried after 20 s
|
|
27
27
|
# OutOfCredits any other 402; never retried
|
|
28
|
+
# OverBudget 402 metadata.reason weight_exceeds_budget: the
|
|
29
|
+
# request alone exceeds the key's budget; an
|
|
30
|
+
# OutOfCredits, never retried
|
|
28
31
|
# ServerError 5xx or a server's error event mid-stream
|
|
29
32
|
# AuthError 401/403, or an API key variable that is not set
|
|
30
33
|
# BadRequest other 4xx (a context overflow is one, whatever status;
|
|
@@ -105,6 +108,15 @@ module Samagotchi
|
|
|
105
108
|
def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
|
|
106
109
|
end
|
|
107
110
|
|
|
111
|
+
# A 402 whose error.metadata.reason is weight_exceeds_budget (OpenRouter):
|
|
112
|
+
# this one request's credit reservation is larger than the key's whole
|
|
113
|
+
# budget, so no amount of waiting lets it through. Never retried, even
|
|
114
|
+
# when the message reads like the in-flight one.
|
|
115
|
+
class OverBudget < OutOfCredits
|
|
116
|
+
def summary = "request too large for the credit budget on host #{host}: #{detail}; " \
|
|
117
|
+
"lower max_tokens (default.max_tokens) or raise the key's credit limit"
|
|
118
|
+
end
|
|
119
|
+
|
|
108
120
|
class ServerError < ProviderError
|
|
109
121
|
def kind = :server
|
|
110
122
|
|
|
@@ -261,6 +273,9 @@ module Samagotchi
|
|
|
261
273
|
REASONING_REFUSED_RE = /reasoning|thinking/i
|
|
262
274
|
# A 402 about credit held by in-flight requests (see CreditsHeld).
|
|
263
275
|
CREDITS_HELD_RE = /in-flight requests/i
|
|
276
|
+
# A 402's error.metadata.reason when the request can never fit the
|
|
277
|
+
# key's budget (see OverBudget).
|
|
278
|
+
OVER_BUDGET_REASON = "weight_exceeds_budget"
|
|
264
279
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
265
280
|
CREDITS_SETTLE_DELAY = 20.0
|
|
266
281
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
@@ -285,7 +300,9 @@ module Samagotchi
|
|
|
285
300
|
when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
|
|
286
301
|
when 408 then ServerError.new(text, retryable: true, **options)
|
|
287
302
|
when 402
|
|
288
|
-
if
|
|
303
|
+
if error_reason(body) == OVER_BUDGET_REASON
|
|
304
|
+
OverBudget.new(text, **options)
|
|
305
|
+
elsif CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
|
|
289
306
|
CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
|
|
290
307
|
else
|
|
291
308
|
OutOfCredits.new(text, **options)
|
|
@@ -356,6 +373,14 @@ module Samagotchi
|
|
|
356
373
|
message.empty? ? "(empty body)" : message[0, 500]
|
|
357
374
|
end
|
|
358
375
|
|
|
376
|
+
# error.metadata.reason of a JSON error body (OpenRouter), or nil.
|
|
377
|
+
def error_reason(body)
|
|
378
|
+
parsed = parse_json(body.to_s)
|
|
379
|
+
error = parsed["error"] if parsed.is_a?(Hash)
|
|
380
|
+
metadata = error["metadata"] if error.is_a?(Hash)
|
|
381
|
+
metadata["reason"] if metadata.is_a?(Hash)
|
|
382
|
+
end
|
|
383
|
+
|
|
359
384
|
# +message+ followed by the upstream provider's name and words from
|
|
360
385
|
# +metadata+ (raw may be text or a JSON error body of its own).
|
|
361
386
|
def with_upstream(message, metadata)
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -36,9 +36,10 @@ module Samagotchi
|
|
|
36
36
|
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
39
|
-
# Requests of these purposes are the ones a turn waits on
|
|
40
|
-
#
|
|
41
|
-
|
|
39
|
+
# Requests of these purposes are the ones a turn waits on (or, for the
|
|
40
|
+
# turn-end warm-up, prepares the next one): INFO lines (and ERROR when
|
|
41
|
+
# they fail). Probes and model lists are DEBUG.
|
|
42
|
+
LOGGED_PURPOSES = %w[chat recap warmup].freeze
|
|
42
43
|
|
|
43
44
|
# Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
|
|
44
45
|
# for up to +max+ retries (max + 1 attempts).
|
|
@@ -127,7 +128,7 @@ module Samagotchi
|
|
|
127
128
|
# @raise [ProviderError] for an error status
|
|
128
129
|
# @raise [FirstTokenTimeout] when nothing was shown in time
|
|
129
130
|
# @param log_fields [Hash] what the log line says about the request
|
|
130
|
-
# (model:, purpose: chat/recap/probe/models); never its body
|
|
131
|
+
# (model:, purpose: chat/recap/warmup/probe/models); never its body
|
|
131
132
|
def stream_lines(uri, request, cancel_controller: nil, on_retry: nil, on_network_error: nil, log_fields: {}, &on_line)
|
|
132
133
|
identify(request)
|
|
133
134
|
current = new_attempt_state(uri, request, log_fields, stream: true)
|