samagotchi 0.23.0 → 0.25.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 +93 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +10 -4
- data/docs/configuration.md +30 -13
- data/docs/hooks.md +21 -7
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +17 -3
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +103 -5
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/chat_loop.rb +37 -13
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +8 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- 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_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +76 -0
- 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_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +96 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +33 -0
- data/lib/samagotchi/web/public/stage_model.js +16 -5
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +9 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# What the running generation is doing, as the Engine's stream handler
|
|
5
|
+
# sees it: whether a steer may cut it (Engine#cut_for_steer). A
|
|
6
|
+
# generation is cuttable while it has only been thinking: it streamed
|
|
7
|
+
# thinking, still streams it (a thinking delta in the last FRESH_SECONDS),
|
|
8
|
+
# and has streamed no visible text and no tool call. Its age counts from
|
|
9
|
+
# its first thinking delta, so the wait for a first token (a busy or
|
|
10
|
+
# remote server) never counts as thinking. Fed from the stream
|
|
11
|
+
# thread, read from a steer's thread: its own Mutex, never held while
|
|
12
|
+
# calling out.
|
|
13
|
+
class GenerationPhase
|
|
14
|
+
# A thinking delta this recent means the model is still thinking.
|
|
15
|
+
FRESH_SECONDS = 2.0
|
|
16
|
+
|
|
17
|
+
# @param clock [#call] monotonic seconds
|
|
18
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@running = false
|
|
22
|
+
clear_seen
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A generation began.
|
|
26
|
+
def started!
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@running = true
|
|
29
|
+
clear_seen
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One streamed chunk's lanes. Blank text (a stray newline before Gemma's
|
|
34
|
+
# thought channel) is no text.
|
|
35
|
+
def chunk!(thinking:, text:, tool_call: false)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
unless thinking.to_s.empty?
|
|
38
|
+
@thinking_at = @clock.call
|
|
39
|
+
@thinking_since ||= @thinking_at
|
|
40
|
+
end
|
|
41
|
+
@text = true unless text.to_s.strip.empty?
|
|
42
|
+
@tool_call = true if tool_call
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The request is sent again (a network retry): it streams from the
|
|
47
|
+
# start, so what it had seen (its thinking clock too) is forgotten.
|
|
48
|
+
def retrying!
|
|
49
|
+
@mutex.synchronize { clear_seen }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The generation ended (completed, cut, cancelled, or the turn ended).
|
|
53
|
+
def finished!
|
|
54
|
+
@mutex.synchronize { @running = false }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether a steer may cut the running generation: thinking only, still
|
|
58
|
+
# thinking, and thinking for at least +min_age+ seconds.
|
|
59
|
+
def cuttable?(min_age)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
next false unless @running && @thinking_since && !@text && !@tool_call
|
|
62
|
+
|
|
63
|
+
now = @clock.call
|
|
64
|
+
now - @thinking_at <= FRESH_SECONDS && now - @thinking_since >= min_age
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Seconds since the running generation's first thinking delta; nil
|
|
69
|
+
# with none.
|
|
70
|
+
def age
|
|
71
|
+
@mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def clear_seen
|
|
77
|
+
@thinking_since = nil
|
|
78
|
+
@thinking_at = nil
|
|
79
|
+
@text = false
|
|
80
|
+
@tool_call = false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -5,13 +5,23 @@ module Samagotchi
|
|
|
5
5
|
# A small shell lexer for the guardrails' text checks (ShellGitDirs
|
|
6
6
|
# and the rules' shell matchers): words with quotes and
|
|
7
7
|
# backslashes undone, operators apart, comments dropped, and $(…) or
|
|
8
|
-
# backticks (quoted or not) as one opaque SUBST marker inside a word.
|
|
9
|
-
#
|
|
8
|
+
# backticks (quoted or not) as one opaque SUBST marker inside a word. A
|
|
9
|
+
# heredoc's body (<<EOF, <<'EOF', <<-EOF) is one word after its <<TAG
|
|
10
|
+
# word and lexing goes on after the terminator line: HEREDOC when cat or
|
|
11
|
+
# tee read it as data, SUBST when an unquoted tag's body holds $(…) or
|
|
12
|
+
# backticks, else the body text itself (a script, as sh -c '…' gives).
|
|
13
|
+
# An unterminated heredoc is no heredoc: the rest is lexed as commands.
|
|
14
|
+
# It never runs anything, and it does its best on odd input (an
|
|
15
|
+
# unbalanced quote).
|
|
10
16
|
module ShellLex
|
|
11
17
|
# Longest first: "&&" before "&", ";;" before ";".
|
|
12
18
|
OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
|
|
13
19
|
# What a $(…) or `…` becomes inside a word.
|
|
14
20
|
SUBST = "\0SUBST"
|
|
21
|
+
# What a heredoc body that cat or tee read as data becomes.
|
|
22
|
+
HEREDOC = "\0HEREDOC"
|
|
23
|
+
# Commands that read a heredoc as data (when not piped on).
|
|
24
|
+
DATA_SINKS = %w[cat tee].freeze
|
|
15
25
|
# A word that is a redirection (>, >>, 2>&1, <).
|
|
16
26
|
REDIRECT = /\A\d*[<>]/
|
|
17
27
|
|
|
@@ -23,6 +33,15 @@ module Samagotchi
|
|
|
23
33
|
Lexer.new(text).tokens
|
|
24
34
|
end
|
|
25
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
|
+
|
|
26
45
|
# The token stream split at operators into word lists; ( and ) become
|
|
27
46
|
# :open / :close markers.
|
|
28
47
|
def self.simple_commands(tokens)
|
|
@@ -47,17 +66,34 @@ module Samagotchi
|
|
|
47
66
|
@s = text
|
|
48
67
|
@i = 0
|
|
49
68
|
@tokens = []
|
|
69
|
+
@spans = []
|
|
70
|
+
@start = 0
|
|
50
71
|
@word = +""
|
|
51
72
|
@specials = +""
|
|
52
73
|
@in_word = false
|
|
74
|
+
@heredocs = []
|
|
75
|
+
@heredoc_spans = []
|
|
76
|
+
@unterminated = false
|
|
53
77
|
end
|
|
54
78
|
|
|
55
79
|
def tokens
|
|
56
80
|
step while @i < @s.size
|
|
57
81
|
flush
|
|
82
|
+
@unterminated = true unless @heredocs.empty?
|
|
58
83
|
@tokens
|
|
59
84
|
end
|
|
60
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
|
+
|
|
61
97
|
private
|
|
62
98
|
|
|
63
99
|
def step
|
|
@@ -76,6 +112,7 @@ module Samagotchi
|
|
|
76
112
|
end
|
|
77
113
|
|
|
78
114
|
def add(text, advance)
|
|
115
|
+
@start = @i unless @in_word
|
|
79
116
|
@word << text
|
|
80
117
|
@in_word = true
|
|
81
118
|
@i += advance
|
|
@@ -88,7 +125,10 @@ module Samagotchi
|
|
|
88
125
|
end
|
|
89
126
|
|
|
90
127
|
def flush
|
|
91
|
-
|
|
128
|
+
if @in_word
|
|
129
|
+
@tokens << [:word, @word.dup, @specials.dup]
|
|
130
|
+
@spans << (@start...[@i, @s.size].min)
|
|
131
|
+
end
|
|
92
132
|
@word.clear
|
|
93
133
|
@specials.clear
|
|
94
134
|
@in_word = false
|
|
@@ -96,11 +136,14 @@ module Samagotchi
|
|
|
96
136
|
end
|
|
97
137
|
|
|
98
138
|
def single_quote
|
|
99
|
-
close = @s.index("'", @i + 1)
|
|
139
|
+
close = @s.index("'", @i + 1)
|
|
140
|
+
@unterminated ||= close.nil?
|
|
141
|
+
close ||= @s.size
|
|
100
142
|
add(@s[(@i + 1)...close].to_s, close + 1 - @i)
|
|
101
143
|
end
|
|
102
144
|
|
|
103
145
|
def double_quote
|
|
146
|
+
@start = @i unless @in_word
|
|
104
147
|
@i += 1
|
|
105
148
|
@in_word = true
|
|
106
149
|
while @i < @s.size && @s[@i] != '"'
|
|
@@ -115,11 +158,14 @@ module Samagotchi
|
|
|
115
158
|
@i += 1
|
|
116
159
|
end
|
|
117
160
|
end
|
|
161
|
+
@unterminated = true if @i >= @s.size
|
|
118
162
|
@i += 1
|
|
119
163
|
end
|
|
120
164
|
|
|
121
165
|
def backtick
|
|
122
|
-
close = @s.index("`", @i + 1)
|
|
166
|
+
close = @s.index("`", @i + 1)
|
|
167
|
+
@unterminated ||= close.nil?
|
|
168
|
+
close ||= @s.size
|
|
123
169
|
add(SUBST, close + 1 - @i)
|
|
124
170
|
end
|
|
125
171
|
|
|
@@ -132,6 +178,7 @@ module Samagotchi
|
|
|
132
178
|
j += 1
|
|
133
179
|
break if depth.zero? || j >= @s.size
|
|
134
180
|
end
|
|
181
|
+
@unterminated = true unless depth.zero?
|
|
135
182
|
add(SUBST, j - @i)
|
|
136
183
|
end
|
|
137
184
|
|
|
@@ -139,8 +186,12 @@ module Samagotchi
|
|
|
139
186
|
@i = @s.index("\n", @i) || @s.size
|
|
140
187
|
end
|
|
141
188
|
|
|
142
|
-
# >, >>, 2>&1, &>, <,
|
|
189
|
+
# >, >>, 2>&1, &>, <, <<<: kept in the word (the walk drops them).
|
|
190
|
+
# << starts a heredoc.
|
|
143
191
|
def redirection
|
|
192
|
+
return add("<<<", 3) if @s[@i, 3] == "<<<"
|
|
193
|
+
return if @s[@i, 2] == "<<" && heredoc
|
|
194
|
+
|
|
144
195
|
special(@s[@i])
|
|
145
196
|
add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.end_with?(">")
|
|
146
197
|
end
|
|
@@ -155,8 +206,109 @@ module Samagotchi
|
|
|
155
206
|
else
|
|
156
207
|
flush
|
|
157
208
|
@tokens << [:op, op]
|
|
209
|
+
@spans << (@i...(@i + op.size))
|
|
158
210
|
@i += op.size
|
|
211
|
+
bodies if op == "\n" && !@heredocs.empty?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# <<TAG, <<-TAG, <<'TAG', << "TAG": its own word, the body noted to
|
|
216
|
+
# be read after the line ends. False (nothing read) without a tag.
|
|
217
|
+
def heredoc
|
|
218
|
+
dash = @s[@i + 2] == "-"
|
|
219
|
+
j = @i + (dash ? 3 : 2)
|
|
220
|
+
j += 1 while [" ", "\t"].include?(@s[j])
|
|
221
|
+
tag, quoted, stop = heredoc_tag(j)
|
|
222
|
+
return false if tag.empty?
|
|
223
|
+
|
|
224
|
+
flush unless @word.match?(/\A\d*\z/)
|
|
225
|
+
special("<")
|
|
226
|
+
@word << "<" << (dash ? "-" : "") << tag
|
|
227
|
+
@i = stop
|
|
228
|
+
flush
|
|
229
|
+
@heredocs << { tag: tag, dash: dash, quoted: quoted, at: @tokens.size }
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The tag starting at +j+ with quotes and backslashes undone, whether
|
|
234
|
+
# any were there, and where it ends.
|
|
235
|
+
def heredoc_tag(j)
|
|
236
|
+
tag = +""
|
|
237
|
+
quoted = false
|
|
238
|
+
while j < @s.size && !@s[j].match?(/[\s;&|<>()]/)
|
|
239
|
+
if ["'", '"'].include?(@s[j])
|
|
240
|
+
close = @s.index(@s[j], j + 1) or return ["", false, j]
|
|
241
|
+
tag << @s[(j + 1)...close]
|
|
242
|
+
j = close + 1
|
|
243
|
+
quoted = true
|
|
244
|
+
elsif @s[j] == "\\"
|
|
245
|
+
tag << @s[j + 1].to_s
|
|
246
|
+
j += 2
|
|
247
|
+
quoted = true
|
|
248
|
+
else
|
|
249
|
+
tag << @s[j]
|
|
250
|
+
j += 1
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
[tag, quoted, j]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# After a line with heredocs: each body in order, up to its
|
|
257
|
+
# terminator line, as one word in its command. Unterminated: none
|
|
258
|
+
# are, and the rest is lexed as commands.
|
|
259
|
+
def bodies
|
|
260
|
+
heredocs = @heredocs
|
|
261
|
+
@heredocs = []
|
|
262
|
+
pos = @i
|
|
263
|
+
start = pos
|
|
264
|
+
read = []
|
|
265
|
+
heredocs.each do |doc|
|
|
266
|
+
body, pos = body(doc, pos)
|
|
267
|
+
break unless body
|
|
268
|
+
|
|
269
|
+
read << [doc, body, start...(@s[pos - 1] == "\n" ? pos - 1 : pos)]
|
|
270
|
+
start = pos
|
|
271
|
+
end
|
|
272
|
+
@unterminated ||= read.size < heredocs.size
|
|
273
|
+
return if read.size < heredocs.size
|
|
274
|
+
|
|
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 } }
|
|
280
|
+
@i = pos
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The body from +pos+ and where lexing goes on, or nil.
|
|
284
|
+
def body(doc, pos)
|
|
285
|
+
start = pos
|
|
286
|
+
while pos < @s.size
|
|
287
|
+
eol = @s.index("\n", pos) || @s.size
|
|
288
|
+
line = @s[pos...eol]
|
|
289
|
+
line = line.sub(/\A\t+/, "") if doc[:dash]
|
|
290
|
+
return [@s[start...pos], [eol + 1, @s.size].min] if line == doc[:tag]
|
|
291
|
+
|
|
292
|
+
pos = eol + 1
|
|
159
293
|
end
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def body_word(doc, body)
|
|
298
|
+
return SUBST if !doc[:quoted] && body.match?(/\$\(|`/)
|
|
299
|
+
return HEREDOC if data_sink?(doc[:at])
|
|
300
|
+
|
|
301
|
+
body
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Whether the command the heredoc at token +at+ is in is cat or tee
|
|
305
|
+
# and not piped into another command.
|
|
306
|
+
def data_sink?(at)
|
|
307
|
+
start = at
|
|
308
|
+
start -= 1 while start.positive? && @tokens[start - 1][0] == :word
|
|
309
|
+
verb = @tokens[start...at].map { |t| t[1] }.find { |w| !w.match?(/\A[A-Za-z_]\w*=/) && !w.match?(REDIRECT) }
|
|
310
|
+
next_op = @tokens[at..].find { |t| t[0] == :op }
|
|
311
|
+
DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
|
|
160
312
|
end
|
|
161
313
|
end
|
|
162
314
|
end
|
|
@@ -114,7 +114,7 @@ module Samagotchi
|
|
|
114
114
|
|
|
115
115
|
# An absolute path for +word+ against +dir+, or nil.
|
|
116
116
|
def resolve(word, dir)
|
|
117
|
-
return nil if word.include?(ShellLex::SUBST) || word.match?(SCRIPT)
|
|
117
|
+
return nil if word.include?(ShellLex::SUBST) || word == ShellLex::HEREDOC || word.match?(SCRIPT)
|
|
118
118
|
|
|
119
119
|
word = expand(word) or return nil
|
|
120
120
|
return nil if word.match?(GLOB)
|
|
@@ -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 })
|
|
@@ -63,8 +63,10 @@ module Samagotchi
|
|
|
63
63
|
|
|
64
64
|
case message["role"]
|
|
65
65
|
when "user"
|
|
66
|
-
# A plugin's steer is a prod to the model, not what the user said
|
|
67
|
-
|
|
66
|
+
# A plugin's steer is a prod to the model, not what the user said;
|
|
67
|
+
# a continue steer from the user or the parent agent is an
|
|
68
|
+
# instruction, kept.
|
|
69
|
+
next nil if Steer.steer?(message) && !Steer.person?(message)
|
|
68
70
|
|
|
69
71
|
# An image is a line naming it (refs only, never its bytes), unless
|
|
70
72
|
# the text is only the web's placeholder naming it already.
|
|
@@ -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"
|
|
@@ -238,8 +239,10 @@ module Samagotchi
|
|
|
238
239
|
# One generation: the response (nil when cut), the cut's detail, this
|
|
239
240
|
# generation's own server counts, the model the server named, the
|
|
240
241
|
# thinking it streamed, and why it stopped (the transport's finish
|
|
241
|
-
# reason, Client::Transport#finish_reason_from; nil when not named)
|
|
242
|
-
|
|
242
|
+
# reason, Client::Transport#finish_reason_from; nil when not named),
|
|
243
|
+
# and its prompt-cache counts (the largest seen: they come cumulative).
|
|
244
|
+
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
|
|
245
|
+
keyword_init: true)
|
|
243
246
|
private_constant :Turn, :Request, :Generation
|
|
244
247
|
|
|
245
248
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
@@ -328,6 +331,7 @@ module Samagotchi
|
|
|
328
331
|
on_retry: lambda { |retry_event|
|
|
329
332
|
# The retry streams from the start: its counts replace these.
|
|
330
333
|
generation.usage = nil
|
|
334
|
+
generation.cache = nil
|
|
331
335
|
generation.streamed_thinking = 0
|
|
332
336
|
generation.finish_reason = nil
|
|
333
337
|
turn.emit.call({ type: :generation_retrying, iteration: turn.iteration }.merge(retry_event)) if turn.on_stream_event
|
|
@@ -346,6 +350,7 @@ module Samagotchi
|
|
|
346
350
|
|
|
347
351
|
def stream_chunk(turn, generation, stream_splitter, chunk)
|
|
348
352
|
generation.usage = turn.context.capture(chunk[:payload]) || generation.usage
|
|
353
|
+
note_cache_counts(generation, chunk[:payload])
|
|
349
354
|
# llama.cpp names the loaded model in the stream's last payload.
|
|
350
355
|
named = chunk[:payload]["model"] if chunk[:payload].is_a?(Hash)
|
|
351
356
|
generation.served_model = named if named.is_a?(String) && !named.strip.empty?
|
|
@@ -355,10 +360,27 @@ module Samagotchi
|
|
|
355
360
|
generation.streamed_thinking += split[:thinking].to_s.length
|
|
356
361
|
return unless turn.on_stream_event
|
|
357
362
|
|
|
358
|
-
|
|
359
|
-
|
|
363
|
+
event = { type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
|
|
364
|
+
thinking: split[:thinking], payload: chunk[:payload] }
|
|
365
|
+
# A chunk of a tool call (its bytes are dropped from both lanes): a
|
|
366
|
+
# steer doesn't cut this generation (Engine#cut_for_steer).
|
|
367
|
+
event[:tool_call] = true if split[:tool]
|
|
368
|
+
emit(turn, **event)
|
|
360
369
|
end
|
|
361
370
|
|
|
371
|
+
# The payload's prompt-cache counts, kept as the largest seen.
|
|
372
|
+
def note_cache_counts(generation, payload)
|
|
373
|
+
counts = TokenUsage.from_payload(payload)
|
|
374
|
+
return unless counts
|
|
375
|
+
|
|
376
|
+
cache = generation.cache ||= { prompt: 0, cached: 0, cache_write: 0 }
|
|
377
|
+
cache[:prompt] = [cache[:prompt], counts.prompt_tokens.to_i].max
|
|
378
|
+
cache[:cached] = [cache[:cached], counts.cached_tokens.to_i].max
|
|
379
|
+
cache[:cache_write] = [cache[:cache_write], counts.cache_write_tokens.to_i].max
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def cache_fields(cache) = cache ? TokenUsage.cache_fields(**cache) : {}
|
|
383
|
+
|
|
362
384
|
# The cut stream's visible text goes with it: the buffer as it was
|
|
363
385
|
# before (the next generation gets a fresh splitter).
|
|
364
386
|
def cut_generation(turn, generation, buffer_mark)
|
|
@@ -376,7 +398,7 @@ module Samagotchi
|
|
|
376
398
|
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
377
399
|
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
378
400
|
served_model: generation.served_model, requested_model: turn.model_name,
|
|
379
|
-
finish_reason: generation.finish_reason)
|
|
401
|
+
finish_reason: generation.finish_reason, **cache_fields(generation.cache))
|
|
380
402
|
dump_log("response", generation.response, iteration: turn.iteration)
|
|
381
403
|
# The after_generation hook (after the model returns, before the tool
|
|
382
404
|
# parse), with a read-only copy of the conversation as sent.
|
|
@@ -386,18 +408,24 @@ module Samagotchi
|
|
|
386
408
|
messages: AnswerDisplay.strip_all(turn.conversation).map(&:dup).freeze })
|
|
387
409
|
end
|
|
388
410
|
|
|
389
|
-
# A generation a plugin cut is an empty answer made early
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
411
|
+
# A generation a plugin cut is an empty answer made early. Queued input
|
|
412
|
+
# (a user's line, a plugin's steer) goes in first, with or without a
|
|
413
|
+
# retry left, and spends no attempt: the model answers it. A steer's cut
|
|
414
|
+
# (Engine#cut_for_steer) with nothing queued (its message was taken at
|
|
415
|
+
# a boundary already) is asked again as is. Else it is asked again with
|
|
416
|
+
# its own nudge while the retry budget lasts, else the turn ends as
|
|
417
|
+
# cancelled (hook), with nothing salvaged and without the spent nudge.
|
|
418
|
+
# A Stop that came right after the cut is a plain cancel. Returns :next
|
|
419
|
+
# or the turn's result.
|
|
394
420
|
def after_cut(turn, generation)
|
|
395
421
|
cut = generation.cut
|
|
396
422
|
raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller.cancelled?
|
|
397
423
|
|
|
398
|
-
|
|
399
|
-
|
|
424
|
+
# The row under the cut step, above the message it was cut for.
|
|
425
|
+
emit(turn, type: :steer_cut, iteration: turn.iteration, source: cut[:source].to_s) if cut[:steer]
|
|
426
|
+
return :next if inject_pending_input!(turn) || cut[:steer]
|
|
400
427
|
|
|
428
|
+
if turn.empty_retry.left?
|
|
401
429
|
turn.empty_retry.nudge!(turn.conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
|
|
402
430
|
emit: turn.emit, iteration: turn.iteration, finish_reason: "stopped",
|
|
403
431
|
thinking_chars: generation.streamed_thinking, stopped_by: cut[:by])
|
|
@@ -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,9 +108,11 @@ 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.
|
|
114
|
+
content = Steer.header(entry) ? Steer.wire_text(entry, entry[:content].to_s) : entry[:content]
|
|
115
|
+
wire << { role: "user", content: parts(content, items) }
|
|
111
116
|
when "model"
|
|
112
117
|
wire << assistant_message(entry, paired)
|
|
113
118
|
when "tool_response"
|
|
@@ -130,6 +135,16 @@ module Samagotchi
|
|
|
130
135
|
wire
|
|
131
136
|
end
|
|
132
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
|
+
|
|
133
148
|
TOOL_IMAGES_TEXT = "[images from tool results]"
|
|
134
149
|
|
|
135
150
|
# The turn's VisionContext (the Engine sets the kernel's
|
|
@@ -381,17 +396,22 @@ module Samagotchi
|
|
|
381
396
|
end
|
|
382
397
|
|
|
383
398
|
# A generation a plugin cut (stop_generation) is an empty answer made
|
|
384
|
-
# early
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
399
|
+
# early. Queued input (a user's line, a plugin's steer) goes in
|
|
400
|
+
# first, with or without a retry left, and spends no attempt. A
|
|
401
|
+
# steer's cut (Engine#cut_for_steer) with nothing queued is asked
|
|
402
|
+
# again as is. Else it is asked again with its own nudge while the
|
|
403
|
+
# retry budget lasts, else the turn ends as cancelled (hook), with
|
|
404
|
+
# nothing salvaged and without a spent nudge. A Stop that came right
|
|
405
|
+
# after the cut is a plain cancel. Returns :retry, or the turn's
|
|
406
|
+
# result.
|
|
389
407
|
def after_cut(iteration, response)
|
|
390
408
|
return canceled(iteration, @cancel_controller.reason) if @cancel_controller.cancelled?
|
|
391
409
|
|
|
392
|
-
|
|
393
|
-
|
|
410
|
+
# The row under the cut step, above the message it was cut for.
|
|
411
|
+
emit(type: :steer_cut, iteration: iteration, source: response.cut[:source].to_s) if response.cut[:steer]
|
|
412
|
+
return :retry if inject_pending_input(iteration) || response.cut[:steer]
|
|
394
413
|
|
|
414
|
+
if @empty_retry.left?
|
|
395
415
|
@empty_retry.nudge!(@conversation, TurnNote.cut_retry(response.cut[:by], response.cut[:reason]),
|
|
396
416
|
emit: method(:emit), iteration: iteration, finish_reason: response.finish_reason,
|
|
397
417
|
thinking_chars: response.reasoning.to_s.length, stopped_by: response.cut[:by])
|
|
@@ -441,7 +461,8 @@ module Samagotchi
|
|
|
441
461
|
record_context_status(response.usage, window)
|
|
442
462
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
443
463
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
444
|
-
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 || {}))
|
|
445
466
|
dump_response(response, iteration)
|
|
446
467
|
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
447
468
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
@@ -478,8 +499,11 @@ module Samagotchi
|
|
|
478
499
|
on_delta: lambda { |content:, reasoning:, payload:|
|
|
479
500
|
streamed << content
|
|
480
501
|
thought << reasoning
|
|
481
|
-
|
|
482
|
-
|
|
502
|
+
event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
503
|
+
thinking: reasoning, payload: payload }
|
|
504
|
+
# A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
|
|
505
|
+
event[:tool_call] = true if OpenAIChat.tool_call_delta?(payload)
|
|
506
|
+
emit(event)
|
|
483
507
|
},
|
|
484
508
|
on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
|
|
485
509
|
)
|