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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +10 -4
  5. data/docs/configuration.md +30 -13
  6. data/docs/hooks.md +21 -7
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +17 -3
  10. data/docs/sub-agent.md +4 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bridge/card_store.rb +4 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  14. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +7 -0
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  18. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  20. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  21. data/lib/samagotchi/command_steps.rb +233 -0
  22. data/lib/samagotchi/config.rb +9 -2
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  24. data/lib/samagotchi/engine.rb +103 -5
  25. data/lib/samagotchi/generation_phase.rb +83 -0
  26. data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
  27. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  28. data/lib/samagotchi/idle_client.rb +14 -2
  29. data/lib/samagotchi/idle_recap.rb +4 -2
  30. data/lib/samagotchi/kernel_loop.rb +40 -12
  31. data/lib/samagotchi/llm/chat_loop.rb +37 -13
  32. data/lib/samagotchi/llm/errors.rb +26 -1
  33. data/lib/samagotchi/llm/openai_chat.rb +8 -4
  34. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  35. data/lib/samagotchi/llm/usage.rb +18 -3
  36. data/lib/samagotchi/log_subscriber.rb +5 -3
  37. data/lib/samagotchi/plugin/api.rb +3 -2
  38. data/lib/samagotchi/prompt.rb +4 -1
  39. data/lib/samagotchi/session_metrics.rb +38 -4
  40. data/lib/samagotchi/steer.rb +103 -8
  41. data/lib/samagotchi/system_prompt.rb +30 -9
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  44. data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
  45. data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
  46. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  47. data/lib/samagotchi/terminal_ui.rb +5 -0
  48. data/lib/samagotchi/thinking_tails.rb +103 -0
  49. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  50. data/lib/samagotchi/token_usage.rb +18 -6
  51. data/lib/samagotchi/tool_activity.rb +37 -3
  52. data/lib/samagotchi/tool_declarations.rb +14 -0
  53. data/lib/samagotchi/tool_runner.rb +7 -0
  54. data/lib/samagotchi/tool_view.rb +76 -0
  55. data/lib/samagotchi/tools/builtins.rb +4 -1
  56. data/lib/samagotchi/tools/execute.rb +2 -1
  57. data/lib/samagotchi/tools/registry.rb +11 -4
  58. data/lib/samagotchi/tools/shell.rb +32 -0
  59. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  60. data/lib/samagotchi/turn_tally.rb +17 -6
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/waiting_steer.rb +64 -0
  63. data/lib/samagotchi/web/message_parts.rb +5 -0
  64. data/lib/samagotchi/web/public/activity.js +5 -0
  65. data/lib/samagotchi/web/public/app.js +2 -1
  66. data/lib/samagotchi/web/public/command_view.js +96 -0
  67. data/lib/samagotchi/web/public/copy.js +7 -0
  68. data/lib/samagotchi/web/public/format.js +11 -1
  69. data/lib/samagotchi/web/public/index.html +33 -0
  70. data/lib/samagotchi/web/public/stage_model.js +16 -5
  71. data/lib/samagotchi/web/public/stage_view.js +5 -2
  72. data/lib/samagotchi/web/public/turn_events.js +15 -1
  73. data/lib/samagotchi/web/public/turn_view.js +15 -6
  74. data/lib/samagotchi/worker.rb +4 -1
  75. 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. It never runs
9
- # anything, and it does its best on odd input (an unbalanced quote).
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
- @tokens << [:word, @word.dup, @specials.dup] if @in_word
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) || @s.size
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) || @s.size
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, &>, <, <<: kept in the word (the walk drops them).
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
- next nil if Steer.steer?(message)
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
- Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, keyword_init: true)
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
- emit(turn, type: :generation_chunk, iteration: turn.iteration, content: chunk[:content], text: split[:text],
359
- thinking: split[:thinking], payload: chunk[:payload])
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: asked again
390
- # with its own nudge while the retry budget lasts (queued input goes in
391
- # place of the nudge), else the turn ends as cancelled (hook), with
392
- # nothing salvaged and without the spent nudge. A Stop that came right
393
- # after the cut is a plain cancel. Returns :next or the turn's result.
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
- if turn.empty_retry.left?
399
- return :next if inject_pending_input!(turn)
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
- def initialize(kernel:, adapter: nil)
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 << { role: "system", content: entry[:content].to_s }
111
+ wire << system_message(entry, index)
109
112
  when "user"
110
- wire << { role: "user", content: parts(entry[:content], items) }
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: asked again with its own nudge while the retry budget lasts
385
- # (queued input goes in place of the nudge), else the turn ends as
386
- # cancelled (hook), with nothing salvaged and without a spent nudge.
387
- # A Stop that came right after the cut is a plain cancel. Returns
388
- # :retry, or the turn's result.
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
- if @empty_retry.left?
393
- return :retry if inject_pending_input(iteration)
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
- emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
482
- thinking: reasoning, payload: payload)
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
  )