samagotchi 0.24.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 +54 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +4 -3
- data/docs/configuration.md +21 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +16 -2
- data/docs/testing.md +12 -0
- 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 +5 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +23 -4
- data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/kernel_loop.rb +21 -3
- data/lib/samagotchi/llm/chat_loop.rb +17 -3
- data/lib/samagotchi/llm/errors.rb +26 -1
- 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/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/thinking_tails.rb +103 -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_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/command_view.js +71 -3
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +25 -0
- 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
- metadata +5 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/shell_lex"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# An execute command as the steps a person reads, for the web's tool row
|
|
7
|
+
# (display only: the guardrails read the command themselves). Built from
|
|
8
|
+
# ShellLex's tokens and their spans, so each step's text is a slice of the
|
|
9
|
+
# command as the model wrote it. Rules, in order:
|
|
10
|
+
#
|
|
11
|
+
# - a heredoc's body leaves its step's text and becomes its +heredoc+
|
|
12
|
+
# ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
|
|
13
|
+
# - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
|
|
14
|
+
# one step; +op+ is the operator before a step (nil for the first);
|
|
15
|
+
# - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
|
|
16
|
+
# - a pipeline's last "| head …" / "| tail …" becomes the +limit+ of the
|
|
17
|
+
# step before it ("head 20", "tail", "head -c 60");
|
|
18
|
+
# - a lone echo of a marker-wrapped string ("=== x ===", "--- x ---",
|
|
19
|
+
# "## x") becomes the +label+ of the next step; a bare marker echo
|
|
20
|
+
# ("---", "====", "") only spaces the output and goes;
|
|
21
|
+
# - anything the parser isn't sure of (a quote or heredoc left open,
|
|
22
|
+
# for/while/until/if/case/function bodies, { … } groups, unbalanced
|
|
23
|
+
# parentheses) gives nil: the UI then shows the title and the raw
|
|
24
|
+
# command.
|
|
25
|
+
module CommandSteps
|
|
26
|
+
Step = Data.define(:text, :op, :label, :limit, :heredoc) do
|
|
27
|
+
def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
|
|
28
|
+
|
|
29
|
+
def to_h = super.compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Parsed = Data.define(:cd, :steps) do
|
|
33
|
+
def to_h = { cd: cd, steps: steps.map(&:to_h) }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Lex = Guardrails::ShellLex
|
|
37
|
+
|
|
38
|
+
SPLIT = ["&&", "||", ";", "|", "|&", "&", "\n"].freeze
|
|
39
|
+
KEYWORDS = %w[for while until if then elif else fi case esac select do done function { }].freeze
|
|
40
|
+
LIMITERS = %w[head tail].freeze
|
|
41
|
+
MARKER = /\A\s*(?:={2,}|-{2,}|#+)\s*(.*?)\s*(?:={2,}|-{2,})?\s*\z/m
|
|
42
|
+
ECHO_FLAGS = %w[-e -n -en -ne].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @return [Parsed, nil] nil when the command isn't plain enough to show
|
|
47
|
+
# as steps
|
|
48
|
+
def parse(command)
|
|
49
|
+
text = command.to_s
|
|
50
|
+
lexer = Lex::Lexer.new(text)
|
|
51
|
+
tokens = lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
|
|
52
|
+
return nil if lexer.unterminated? || tokens.empty?
|
|
53
|
+
|
|
54
|
+
bodies = lexer.heredoc_spans.to_h { |doc| [doc[:span], doc[:tag]] }
|
|
55
|
+
raw = split(tokens)
|
|
56
|
+
return nil if raw.nil? || raw.empty?
|
|
57
|
+
|
|
58
|
+
steps = raw.map { |op, words| step(text, op, words, bodies) }
|
|
59
|
+
return nil if steps.any?(&:nil?)
|
|
60
|
+
|
|
61
|
+
cd, steps = leading_cd(steps, raw)
|
|
62
|
+
steps = labels(limits(steps))
|
|
63
|
+
steps.empty? ? nil : Parsed.new(cd: cd, steps: steps)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# [[op, word tokens]] split at SPLIT outside parentheses; nil for a
|
|
67
|
+
# control keyword at a command's start, ;; or unbalanced parentheses.
|
|
68
|
+
def split(tokens)
|
|
69
|
+
steps = []
|
|
70
|
+
op = nil
|
|
71
|
+
words = []
|
|
72
|
+
depth = 0
|
|
73
|
+
tokens.each do |token|
|
|
74
|
+
kind, value = token
|
|
75
|
+
if kind == :op && value == "("
|
|
76
|
+
depth += 1
|
|
77
|
+
words << token
|
|
78
|
+
elsif kind == :op && value == ")"
|
|
79
|
+
depth -= 1
|
|
80
|
+
return nil if depth.negative?
|
|
81
|
+
|
|
82
|
+
words << token
|
|
83
|
+
elsif kind == :op && depth.zero?
|
|
84
|
+
return nil unless SPLIT.include?(value)
|
|
85
|
+
|
|
86
|
+
if words.empty?
|
|
87
|
+
op = value unless value == "\n" # "a;\nb" keeps the ;
|
|
88
|
+
else
|
|
89
|
+
steps << [steps.empty? ? nil : op, words]
|
|
90
|
+
op = value
|
|
91
|
+
end
|
|
92
|
+
words = []
|
|
93
|
+
else
|
|
94
|
+
return nil if keyword?(words, token)
|
|
95
|
+
|
|
96
|
+
words << token
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
return nil unless depth.zero?
|
|
100
|
+
|
|
101
|
+
steps << [steps.empty? ? nil : op, words] unless words.empty?
|
|
102
|
+
steps
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether +token+ is a control keyword where a command starts (the
|
|
106
|
+
# step's first word, or the first after a "(" inside it).
|
|
107
|
+
def keyword?(words, token)
|
|
108
|
+
return false unless token[0] == :word
|
|
109
|
+
|
|
110
|
+
at_start = words.empty? || words.last[0] == :op
|
|
111
|
+
at_start && KEYWORDS.include?(token[1])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A Step from its tokens: the text from the first to the last token,
|
|
115
|
+
# heredoc bodies cut out.
|
|
116
|
+
def step(text, op, tokens, bodies)
|
|
117
|
+
body = tokens.find { |t| bodies.key?(t.last) }
|
|
118
|
+
shown = tokens.reject { |t| bodies.key?(t.last) }
|
|
119
|
+
from = shown.map { |t| t.last.begin }.min
|
|
120
|
+
to = shown.map { |t| t.last.end }.max
|
|
121
|
+
return nil unless from
|
|
122
|
+
|
|
123
|
+
slice = text[from...to]
|
|
124
|
+
heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
|
|
125
|
+
slice, heredoc = inner_heredoc(slice) unless heredoc
|
|
126
|
+
Step.new(text: slice.strip, op: op, heredoc: heredoc)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
|
|
130
|
+
# the slice with the body cut out, and the heredoc; or the slice as is.
|
|
131
|
+
def inner_heredoc(slice)
|
|
132
|
+
return [slice, nil] unless slice.include?("<<")
|
|
133
|
+
|
|
134
|
+
at = 0
|
|
135
|
+
while (open = slice.index("$(", at))
|
|
136
|
+
lexer = Lex::Lexer.new(slice[(open + 2)..])
|
|
137
|
+
lexer.tokens
|
|
138
|
+
doc = lexer.heredoc_spans.first
|
|
139
|
+
if doc
|
|
140
|
+
from = open + 2 + doc[:span].begin - 1 # the newline before the body
|
|
141
|
+
to = open + 2 + doc[:span].end
|
|
142
|
+
to += 1 if slice[to] == "\n"
|
|
143
|
+
return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
|
|
144
|
+
end
|
|
145
|
+
at = open + 2
|
|
146
|
+
end
|
|
147
|
+
[slice, nil]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The body's line count, its terminator line not counted.
|
|
151
|
+
def body_lines(body_and_tag) = [body_and_tag.to_s.chomp.lines.size - 1, 0].max
|
|
152
|
+
|
|
153
|
+
# A first "cd X" step followed by && or ; becomes the cd.
|
|
154
|
+
def leading_cd(steps, raw)
|
|
155
|
+
words = raw.first[1]
|
|
156
|
+
next_op = steps[1]&.op
|
|
157
|
+
plain = words.size == 2 && words.all? { |t| t[0] == :word } && !words[1][1].include?(Lex::SUBST)
|
|
158
|
+
return [nil, steps] unless plain && words[0][1] == "cd" && ["&&", ";"].include?(next_op)
|
|
159
|
+
|
|
160
|
+
[words[1][1], [steps[1].with(op: nil), *steps.drop(2)]]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A pipeline's last head/tail becomes the limit of the step before it.
|
|
164
|
+
def limits(steps)
|
|
165
|
+
steps.each_with_index.with_object([]) do |(step, i), out|
|
|
166
|
+
limit = step.op == "|" && !out.empty? && out.last.limit.nil? && limit_of(step.text)
|
|
167
|
+
if limit && !["|", "|&"].include?(steps[i + 1]&.op)
|
|
168
|
+
out[-1] = out.last.with(limit: limit)
|
|
169
|
+
else
|
|
170
|
+
out << step
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# "head 20" for "head -n 20" / "head -20" / "head -n20"; "head -c 60";
|
|
176
|
+
# "tail +5"; "head" alone. nil for anything else (tail -f, a file).
|
|
177
|
+
def limit_of(text)
|
|
178
|
+
words = Lex.lex(text)
|
|
179
|
+
return nil unless words.all? { |t| t[0] == :word }
|
|
180
|
+
|
|
181
|
+
name, *args = words.map { |t| t[1] }
|
|
182
|
+
return nil unless LIMITERS.include?(name)
|
|
183
|
+
|
|
184
|
+
count = case args
|
|
185
|
+
in [] then nil
|
|
186
|
+
in [/\A-\d+\z/ => arg] then arg[1..]
|
|
187
|
+
in [/\A-n\+?\d+\z/ => arg] then arg[2..]
|
|
188
|
+
in ["-n", /\A\+?\d+\z/ => arg] then arg
|
|
189
|
+
in ["-c", /\A\d+\z/ => arg] then "-c #{arg}"
|
|
190
|
+
else return nil
|
|
191
|
+
end
|
|
192
|
+
[name, count].compact.join(" ")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A marker echo labels the next step; a bare one goes.
|
|
196
|
+
# A dropped echo's op goes to the step after it (the link from the
|
|
197
|
+
# step before).
|
|
198
|
+
def labels(steps)
|
|
199
|
+
out = []
|
|
200
|
+
label = nil
|
|
201
|
+
op = nil
|
|
202
|
+
steps.each_with_index do |step, i|
|
|
203
|
+
marker = marker_label(step.text)
|
|
204
|
+
if marker.nil? || (!marker.empty? && i == steps.size - 1)
|
|
205
|
+
step = step.with(label: label, op: op || step.op) if op
|
|
206
|
+
out << (out.empty? ? step.with(op: nil) : step)
|
|
207
|
+
label = op = nil
|
|
208
|
+
else
|
|
209
|
+
label = marker unless marker.empty?
|
|
210
|
+
op ||= step.op || "\n"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The label of a lone marker echo ("" for a bare marker or an empty
|
|
217
|
+
# echo), nil for any other step.
|
|
218
|
+
def marker_label(text)
|
|
219
|
+
words = Lex.lex(text)
|
|
220
|
+
return nil unless words.all? { |t| t[0] == :word && !t[1].include?(Lex::SUBST) }
|
|
221
|
+
|
|
222
|
+
name, *args = words.map { |t| t[1] }
|
|
223
|
+
args.shift while ECHO_FLAGS.include?(args.first)
|
|
224
|
+
return nil unless name == "echo" && args.size <= 1
|
|
225
|
+
return "" if args.empty? || args[0].strip.empty?
|
|
226
|
+
|
|
227
|
+
match = MARKER.match(args[0])
|
|
228
|
+
return nil unless match && args[0].strip.match?(/\A(?:={2,}|-{2,}|#)/)
|
|
229
|
+
|
|
230
|
+
match[1].strip
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -174,6 +174,8 @@ module Samagotchi
|
|
|
174
174
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
175
175
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
176
176
|
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
177
|
+
# Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
|
|
178
|
+
Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
|
|
177
179
|
|
|
178
180
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
179
181
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
@@ -666,8 +668,9 @@ module Samagotchi
|
|
|
666
668
|
# A `sampling:` setting (hosts entry or models: entry): request parameters
|
|
667
669
|
# passed through to the provider as written (keys symbolized, nested maps
|
|
668
670
|
# too, so e.g. chat_template_kwargs works). A null value means "don't
|
|
669
|
-
# send it" (
|
|
670
|
-
#
|
|
671
|
+
# send it" (a null temperature also keeps the empty-answer retry from
|
|
672
|
+
# setting one). Not a map: warns once, nil. Reserved keys warn once and
|
|
673
|
+
# are dropped. nil when nothing is left.
|
|
671
674
|
def sampling_map(value, where)
|
|
672
675
|
return nil if value.nil?
|
|
673
676
|
|
|
@@ -13,8 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# when the retries run out.
|
|
14
14
|
class EmptyAnswerRetry
|
|
15
15
|
MAX = 3
|
|
16
|
-
#
|
|
17
|
-
# the retry runs at this temperature unless one is configured.
|
|
16
|
+
# The same (or greedy) decoding on a near-identical prompt would likely
|
|
17
|
+
# loop again: the retry runs at this temperature unless one is configured.
|
|
18
18
|
TEMPERATURE = 0.6
|
|
19
19
|
# A generation cut at the provider's output cap (finish_reason length)
|
|
20
20
|
# is retried unless the context is this full.
|
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"
|
|
@@ -172,8 +173,9 @@ module Samagotchi
|
|
|
172
173
|
@hooks = load_hooks_from_config
|
|
173
174
|
load_hooks_from_bundles
|
|
174
175
|
# The tools this session offers (the prompts' declarations and the
|
|
175
|
-
# kernel's dispatch): the built-ins, per Engine.
|
|
176
|
-
|
|
176
|
+
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
177
|
+
# off: execute offers no description parameter.
|
|
178
|
+
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
|
|
177
179
|
if @scratch
|
|
178
180
|
# A scratch session's children would outlive it, and so would a
|
|
179
181
|
# note it left in a peer; its memories would too (write and edit
|
|
@@ -282,6 +284,10 @@ module Samagotchi
|
|
|
282
284
|
@session_observer.subscribe(observer: @metrics)
|
|
283
285
|
# And the event trail in the debug log (`turn` records).
|
|
284
286
|
@session_observer.subscribe(observer: LogSubscriber.new(session_id: -> { @session&.id }))
|
|
287
|
+
# The thinking of cut and capped generations, kept in the session dir.
|
|
288
|
+
@session_observer.subscribe(observer: ThinkingTails.new(session_dir: lambda {
|
|
289
|
+
@session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
|
|
290
|
+
}))
|
|
285
291
|
end
|
|
286
292
|
|
|
287
293
|
# A monotonically-increasing clock (wall clock can jump backwards; the idle
|
|
@@ -2039,12 +2045,23 @@ module Samagotchi
|
|
|
2039
2045
|
nil
|
|
2040
2046
|
end
|
|
2041
2047
|
|
|
2048
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
2049
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
2050
|
+
# with thinking off), true once a real thought came.
|
|
2051
|
+
def note_generation_thought(thinking)
|
|
2052
|
+
return if thinking.empty?
|
|
2053
|
+
|
|
2054
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
2055
|
+
end
|
|
2056
|
+
|
|
2042
2057
|
# Thinking off, and the model thought anyway: logged each time, said
|
|
2043
|
-
# once per session and host.
|
|
2058
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
2059
|
+
# thought.
|
|
2044
2060
|
def check_thinking_honoured(event)
|
|
2045
2061
|
level, target = @turn_thinking
|
|
2046
2062
|
chars = event[:thinking_chars].to_i
|
|
2047
2063
|
return unless level == :off && chars.positive? && target
|
|
2064
|
+
return if @generation_thought == false
|
|
2048
2065
|
|
|
2049
2066
|
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
2050
2067
|
thinking_notice_once(:not_honoured, target, :warn,
|
|
@@ -2184,7 +2201,7 @@ module Samagotchi
|
|
|
2184
2201
|
return @native_backend unless target.entry.chat?
|
|
2185
2202
|
|
|
2186
2203
|
@chat_backend_mutex.synchronize do
|
|
2187
|
-
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel)
|
|
2204
|
+
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
|
|
2188
2205
|
@chat_backend.adapter = @host_registry.adapter_for(target.entry)
|
|
2189
2206
|
@chat_backend.session_id = session&.id
|
|
2190
2207
|
@chat_backend
|
|
@@ -2406,6 +2423,7 @@ module Samagotchi
|
|
|
2406
2423
|
case event[:type]
|
|
2407
2424
|
when :generation_started
|
|
2408
2425
|
@generation_phase.started!
|
|
2426
|
+
@generation_thought = nil
|
|
2409
2427
|
watch&.started(event[:iteration])
|
|
2410
2428
|
when :generation_chunk
|
|
2411
2429
|
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
@@ -2413,6 +2431,7 @@ module Samagotchi
|
|
|
2413
2431
|
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2414
2432
|
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2415
2433
|
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2434
|
+
note_generation_thought(progress[:thinking])
|
|
2416
2435
|
when :generation_retrying then @generation_phase.retrying!
|
|
2417
2436
|
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2418
2437
|
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"
|
|
@@ -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?
|
|
@@ -363,6 +368,19 @@ module Samagotchi
|
|
|
363
368
|
emit(turn, **event)
|
|
364
369
|
end
|
|
365
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
|
+
|
|
366
384
|
# The cut stream's visible text goes with it: the buffer as it was
|
|
367
385
|
# before (the next generation gets a fresh splitter).
|
|
368
386
|
def cut_generation(turn, generation, buffer_mark)
|
|
@@ -380,7 +398,7 @@ module Samagotchi
|
|
|
380
398
|
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
381
399
|
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
382
400
|
served_model: generation.served_model, requested_model: turn.model_name,
|
|
383
|
-
finish_reason: generation.finish_reason)
|
|
401
|
+
finish_reason: generation.finish_reason, **cache_fields(generation.cache))
|
|
384
402
|
dump_log("response", generation.response, iteration: turn.iteration)
|
|
385
403
|
# The after_generation hook (after the model returns, before the tool
|
|
386
404
|
# parse), with a read-only copy of the conversation as sent.
|
|
@@ -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 })
|