samagotchi 0.20.0 → 0.22.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +5 -3
  4. data/docs/cli.md +2 -1
  5. data/docs/configuration.md +11 -0
  6. data/docs/plugins.md +22 -8
  7. data/docs/sessions.md +3 -3
  8. data/docs/sub-agent.md +11 -5
  9. data/lib/samagotchi/answer_command.rb +7 -3
  10. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  12. data/lib/samagotchi/bridge.rb +16 -4
  13. data/lib/samagotchi/bridge_client.rb +33 -9
  14. data/lib/samagotchi/bundle_command.rb +6 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  19. data/lib/samagotchi/continue_offer.rb +34 -9
  20. data/lib/samagotchi/engine.rb +47 -4
  21. data/lib/samagotchi/events.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  23. data/lib/samagotchi/llm/errors.rb +31 -0
  24. data/lib/samagotchi/llm/openai_chat.rb +33 -7
  25. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  26. data/lib/samagotchi/log_subscriber.rb +4 -2
  27. data/lib/samagotchi/parent_report.rb +24 -7
  28. data/lib/samagotchi/question_desk.rb +1 -7
  29. data/lib/samagotchi/reply_wait.rb +12 -2
  30. data/lib/samagotchi/session_manager.rb +26 -9
  31. data/lib/samagotchi/session_metrics.rb +6 -4
  32. data/lib/samagotchi/steer.rb +1 -1
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  34. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  35. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  36. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  38. data/lib/samagotchi/turn_state.rb +22 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/public/app.js +34 -7
  41. data/lib/samagotchi/web/public/index.html +7 -0
  42. data/lib/samagotchi/web/public/notifications.js +8 -3
  43. data/lib/samagotchi/web/public/notify.js +49 -7
  44. data/lib/samagotchi/web/public/timing.js +5 -3
  45. data/lib/samagotchi/web/public/turn_events.js +16 -0
  46. metadata +2 -1
@@ -200,6 +200,10 @@ module Samagotchi
200
200
  end
201
201
 
202
202
  # Runs +installer+, prints its summary, then yields the manifest.
203
+ # Ensures the extracted source dir is cleaned up even if an exception
204
+ # occurs between installer.run and the block (e.g. summary,
205
+ # print_hooks_and_plugin). The block (upgrade_conflicts) still sees
206
+ # the files because cleanup happens after the block returns or raises.
203
207
  # @return [Integer] the block's exit status, or 1 after "<failed> failed: …"
204
208
  def run_installer(installer, failed:)
205
209
  _nd, manifest = installer.run
@@ -208,6 +212,8 @@ module Samagotchi
208
212
  rescue Samagotchi::MemoryBundle::Installer::InstallError => e
209
213
  @stderr.puts "#{failed} failed: #{e.message}"
210
214
  1
215
+ ensure
216
+ installer.cleanup_source!
211
217
  end
212
218
 
213
219
  def print_hooks_and_plugin(manifest, plugin: true)
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.2.2
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:e62c68d1a2280adf55b150488d0d85bbbc35ffce54dfed387a86adceb85e4d34
9
+ sha256: sha256:32d04fa3f4b3394e80160dd780615a4a1ddeb3be0f2e327a6497c7766dbd9f11
10
10
  requires_chi: ">= 0.6.0"
@@ -24,6 +24,9 @@
24
24
  # thinking: the thinking watch (ThinkingWatch::DEFAULTS)
25
25
  # watch: true false: the tool-call guard only
26
26
  # action: retry retry (cut, ask again; then stop) | stop | notify
27
+ # forget_after: 10 good steps (generations with no loop) in a row after a
28
+ # cut loop, and the next loop is cut again, not stopped;
29
+ # 0: never forget, the turn's second loop stops it
27
30
  require "digest"
28
31
 
29
32
  # Sees thinking repeat itself, from the deltas of one generation.
@@ -44,12 +47,19 @@ require "digest"
44
47
  # of 2 or more needs a cycle of distinct sentences (not all alike), and a
45
48
  # period of 1 (one sentence over and over) needs `similarity` +
46
49
  # SAME_MARGIN (0.9 by default: near-identical);
47
- # - the same sentence max_same times in the generation, in any order.
50
+ # - the same sentence max_same times in the generation, in any order;
51
+ # - a short run: short_run short sentences (under min_words) in a row, at
52
+ # most short_distinct different ones among the last short_run ("I'll
53
+ # write it. Go. OK. Writing. Go."). Normal thinking has short sentences
54
+ # too ("Hmm.", "Fine."), but spread out, or in runs of different ones
55
+ # (code lines); a sentence of min_words or more ends the run, one with no
56
+ # letters or digits neither counts nor ends it. Lines inside a ``` code
57
+ # block are not short sentences.
48
58
  # Nothing triggers before min_chars of thinking. O(new chars) per feed.
49
59
  class ThinkingWatch
50
60
  DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
51
61
  "similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
52
- "min_words" => 5 }.freeze
62
+ "min_words" => 5, "short_run" => 24, "short_distinct" => 6, "forget_after" => 10 }.freeze
53
63
  ACTIONS = %w[retry stop notify].freeze
54
64
  MAX_CARRY = 400
55
65
  # One sentence again and again needs this much more alike than a cycle.
@@ -68,10 +78,13 @@ class ThinkingWatch
68
78
  out = DEFAULTS.dup
69
79
  out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
70
80
  out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
71
- %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words].each do |key|
81
+ %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words short_run
82
+ short_distinct].each do |key|
72
83
  number = Integer(raw[key].to_s, exception: false)
73
84
  out[key] = number if number&.positive?
74
85
  end
86
+ forget = Integer(raw["forget_after"].to_s, exception: false)
87
+ out["forget_after"] = forget if forget && forget >= 0
75
88
  similarity = Float(raw["similarity"].to_s, exception: false)
76
89
  out["similarity"] = similarity if similarity && similarity.positive? && similarity <= 1
77
90
  out["repeats"] = 2 if out["repeats"] < 2
@@ -87,12 +100,17 @@ class ThinkingWatch
87
100
  @min_span_chars = settings["min_span_chars"]
88
101
  @max_same = settings["max_same"]
89
102
  @min_words = settings["min_words"]
103
+ @short_run = settings["short_run"]
104
+ @short_distinct = settings["short_distinct"]
90
105
  @same_similarity = [@similarity + SAME_MARGIN, 0.95].min
91
106
  @carry = +""
92
107
  @window = [] # the last max_period + 1 sentences
93
108
  @run = Array.new(@max_period + 1, 0)
94
109
  @run_chars = Array.new(@max_period + 1, 0) # chars of the run's sentences plus the first cycle's
95
110
  @counts = Hash.new(0)
111
+ @short_count = 0 # short sentences in a row
112
+ @short_window = [] # the last short_run of them, [key, text]
113
+ @in_code = false
96
114
  @thinking_chars = 0
97
115
  end
98
116
 
@@ -115,7 +133,9 @@ class ThinkingWatch
115
133
  # The complete sentences in the carry; the unfinished tail stays.
116
134
  def sentences
117
135
  parts = @carry.split(SPLIT, -1)
118
- @carry = parts.pop.to_s
136
+ # An empty carry splits into no parts: nil.to_s is frozen, and the next
137
+ # feed's << would raise (the watch went blind for that generation).
138
+ @carry = +parts.pop.to_s
119
139
  if @carry.length > MAX_CARRY
120
140
  cut = @carry.rindex(" ", MAX_CARRY) || MAX_CARRY
121
141
  parts << @carry[0, cut]
@@ -125,8 +145,13 @@ class ThinkingWatch
125
145
  end
126
146
 
127
147
  def add(text)
148
+ if text.strip.start_with?("```")
149
+ @in_code = !@in_code
150
+ return nil
151
+ end
128
152
  words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
129
- return nil if words.length < @min_words
153
+ short = short_loop(text.strip, words) unless @in_code
154
+ return (short if @thinking_chars >= @min_chars) if words.length < @min_words
130
155
 
131
156
  key = words.join(" ")
132
157
  sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
@@ -140,6 +165,25 @@ class ThinkingWatch
140
165
  cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
141
166
  end
142
167
 
168
+ def short_loop(text, words)
169
+ return nil if words.empty?
170
+
171
+ if words.length >= @min_words
172
+ @short_count = 0
173
+ @short_window.clear
174
+ return nil
175
+ end
176
+ @short_count += 1
177
+ @short_window << [words.join(" "), text]
178
+ @short_window.shift if @short_window.length > @short_run
179
+ return nil if @short_window.length < @short_run
180
+
181
+ distinct = @short_window.uniq(&:first)
182
+ return nil if distinct.length > @short_distinct
183
+
184
+ Loop.new(period: distinct.length, times: @short_count / distinct.length, sentences: distinct.first(3).map(&:last), chars: nil)
185
+ end
186
+
143
187
  def count(key)
144
188
  return @counts[key] += 1 if @counts.key?(key) || @counts.size < MAX_DISTINCT
145
189
 
@@ -209,6 +253,7 @@ class Plugin
209
253
  return unless @thinking["watch"]
210
254
 
211
255
  chi.on(:before_generation) do |_event|
256
+ good_step if @watch
212
257
  @watch = ThinkingWatch.new(@thinking)
213
258
  @watch_done = false
214
259
  end
@@ -242,6 +287,16 @@ class Plugin
242
287
  end
243
288
  end
244
289
 
290
+ # The generation before this one ended with no loop: a good step. After
291
+ # forget_after of them in a row the turn's loops are forgotten, so a loop
292
+ # long after the model recovered is cut and retried, not stopped.
293
+ def good_step
294
+ return @good_steps = 0 if @watch_done
295
+
296
+ @good_steps += 1
297
+ @thinking_loops = 0 if @good_steps == @thinking["forget_after"]
298
+ end
299
+
245
300
  def stop_thinking(ctx, found, what)
246
301
  return unless ctx.stop_turn("the model's thinking kept repeating itself (#{what})")
247
302
 
@@ -261,6 +316,7 @@ class Plugin
261
316
  @denials = 0
262
317
  @pending = nil
263
318
  @thinking_loops = 0
319
+ @good_steps = 0
264
320
  @watch = nil
265
321
  end
266
322
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.20.0
3
+ version: 0.22.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -64,10 +64,11 @@ module Samagotchi
64
64
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
65
  @reported = true
66
66
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
- json: @json, timeout: timeout)
67
+ json: @json, timeout: timeout, extra: report_extra)
68
68
  rescue Interrupt
69
69
  if @json
70
- @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
71
+ extra: report_extra))
71
72
  @stdout.flush
72
73
  @reported = true
73
74
  end
@@ -77,6 +78,12 @@ module Samagotchi
77
78
  error_line("#{command_name}: the session is gone (deleted while waiting)")
78
79
  Exit::FAILED
79
80
  end
81
+
82
+ # More keys for the JSON object: answered_here: false when chi
83
+ # answer's answer didn't go in (the question was no longer open).
84
+ def report_extra
85
+ @answered_here == false ? { answered_here: false } : {}
86
+ end
80
87
  end
81
88
  end
82
89
  end
@@ -41,6 +41,9 @@ module Samagotchi
41
41
  # A question was posted since the last close (it may have been
42
42
  # answered or superseded meanwhile).
43
43
  @asked = false
44
+ # The text a Continue answer came with ({text:, source:}): it steers
45
+ # the continue turn (#run_continue_turn).
46
+ @continue_steer = nil
44
47
  end
45
48
 
46
49
  attr_writer :queue_command
@@ -62,6 +65,7 @@ module Samagotchi
62
65
  def drop(origin)
63
66
  return unless awaiting?
64
67
 
68
+ @continue_steer = nil
65
69
  close("dropped")
66
70
  @engine.synchronize_events do
67
71
  @turn_flow.drop_offer!
@@ -95,6 +99,10 @@ module Samagotchi
95
99
  close(resolved ? "answered" : "dropped") unless awaiting?
96
100
  return unless resolved
97
101
 
102
+ # Answered without a turn (a typed /continue no that got there before
103
+ # the answer's /continue yes): the answer's text steers nothing.
104
+ @continue_steer = nil unless result.resume
105
+
98
106
  # An offer answered without a turn ("no") is activity: the recap
99
107
  # written at the offer (it says the turn stopped) gets rewritten.
100
108
  @engine.record_activity unless result.resume
@@ -103,18 +111,26 @@ module Samagotchi
103
111
  # The continue offer was answered yes: resume the conversation without a
104
112
  # user message, with the iteration limit the offer's turn had. A failure
105
113
  # keeps the offer, as the REPL does ("continue prompt preserved"), and
106
- # asks again.
114
+ # asks again. The answer's text is carried into the turn as it begins;
115
+ # a turn that never began (a raise before it) leaves it to no other.
107
116
  def run_continue_turn(command)
108
117
  offer = @turn_flow.offer
109
118
  close("answered")
110
- @turn_flow.before_continue_turn
111
- @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
112
- max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
113
- if error
114
- open(offer)
115
- else
116
- after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
119
+ steer = @continue_steer
120
+ @continue_steer = nil
121
+ begin
122
+ @engine.steer_next_turn(steer[:text], source: steer[:source]) if steer
123
+ @turn_flow.before_continue_turn
124
+ @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
125
+ max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
126
+ if error
127
+ open(offer)
128
+ else
129
+ after_turn(result, continue: true, no_interrupt: offer[:no_interrupt])
130
+ end
117
131
  end
132
+ ensure
133
+ @engine.drop_carried_steers if steer
118
134
  end
119
135
  end
120
136
 
@@ -164,9 +180,18 @@ module Samagotchi
164
180
  end
165
181
 
166
182
  # The question's answer as the /continue line a typed answer would be:
167
- # Continue → yes; Stop → no; a text (with Stop, or alone) → no, <text>.
183
+ # Continue → yes, and a text with it steers the continue turn (from the
184
+ # parent agent when the answer says so); Stop → no; a text (with Stop,
185
+ # or alone) → no, <text>.
168
186
  def answered(answer, client_id:)
187
+ # The /continue line takes the text on one line; the steer keeps its
188
+ # line breaks.
169
189
  text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
190
+ @continue_steer = nil
191
+ if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
192
+ @continue_steer = { text: answer[:freeform].to_s.strip,
193
+ source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
194
+ end
170
195
  line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
171
196
  elsif text.empty? then "/continue no"
172
197
  else "/continue no, #{text}"
@@ -325,6 +325,21 @@ module Samagotchi
325
325
  @turn_state.steer(text, source: source)
326
326
  end
327
327
 
328
+ # Like #steer, for the turn that begins next (a continue turn answered
329
+ # with a text): it joins that turn at its first boundary. Callable from
330
+ # any thread; a turn that never begins keeps it for the next one, unless
331
+ # #drop_carried_steers drops it.
332
+ # @return [Boolean] whether it was queued (false: blank)
333
+ def steer_next_turn(text, source:)
334
+ @turn_state.steer_next_turn(text, source: source)
335
+ end
336
+
337
+ # Drop the #steer_next_turn steers no turn began with (the continue turn
338
+ # failed before it began), logged; a no-op once a turn took them.
339
+ def drop_carried_steers
340
+ log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
341
+ end
342
+
328
343
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
329
344
  # in a --non-interactive run) and the plugin steers. At the after-answer
330
345
  # boundary the steers are dropped: the model answered, and a nudge would
@@ -1089,7 +1104,8 @@ module Samagotchi
1089
1104
  # @param limit [Integer, nil] the turn ran out of iterations at this
1090
1105
  # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
1091
1106
  # answer the continue offer says so (ReplyWait, ParentReport "limit")
1092
- def record_last_turn(session, outcome, seconds, origin, limit: nil)
1107
+ # @param event [Hash] the turn's end event (#stop_facts)
1108
+ def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
1093
1109
  client_id = origin.is_a?(Hash) ? origin[:client_id].to_s : ""
1094
1110
  source = if client_id.start_with?("#{Tools::Delegate::CLIENT_PREFIX}:") then "delegate"
1095
1111
  elsif client_id == SessionManager::REMINDER_CLIENT_ID then "reminder"
@@ -1098,9 +1114,23 @@ module Samagotchi
1098
1114
  session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1099
1115
  "seconds" => seconds.round(1), "origin" => source }
1100
1116
  session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1117
+ session.last_turn.merge!(stop_facts(event))
1101
1118
  end
1102
1119
  private :record_last_turn
1103
1120
 
1121
+ # Why a turn stopped, from its end event, for a parent agent's wait
1122
+ # (ReplyWait): a provider error's kind and whether a retry may help, a
1123
+ # cancel's reason and the hook that stopped it. Only what is known.
1124
+ def stop_facts(event)
1125
+ case event&.dig(:type)
1126
+ when :turn_failed then { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable] }.compact
1127
+ when :turn_canceled
1128
+ { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1129
+ else {}
1130
+ end
1131
+ end
1132
+ private :stop_facts
1133
+
1104
1134
  # How every turn ends (completed, canceled, failed): the session gets
1105
1135
  # what the turn kept, goes idle and records the ending, and the end
1106
1136
  # event is announced, as one step of the event log (a snapshot taken
@@ -1118,7 +1148,7 @@ module Samagotchi
1118
1148
  kept, event = yield(seconds)
1119
1149
  replace_session_messages(turn.session, kept) if kept
1120
1150
  turn.session.status = Session::STATUS_IDLE
1121
- record_last_turn(turn.session, outcome, seconds, turn.origin, limit: exhausted ? turn.limit : nil)
1151
+ record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1122
1152
  emit_event(turn.on_event, turn.tag(event))
1123
1153
  end
1124
1154
  turn.ended = true
@@ -1637,12 +1667,25 @@ module Samagotchi
1637
1667
  def stopped_before_model(turn)
1638
1668
  reason = turn.controller.reason
1639
1669
  end_turn(turn, "canceled") do |seconds|
1640
- [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1670
+ [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
1671
+ duration_ms: (seconds * 1000).round }]
1641
1672
  end
1642
1673
  LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1643
1674
  end
1644
1675
  private :stopped_before_model
1645
1676
 
1677
+ # Who stopped a turn a hook stopped or cut (the controller's detail: the
1678
+ # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
1679
+ # nil for any other cancel.
1680
+ def cancelled_by(turn)
1681
+ return nil unless turn.controller.reason == :hook
1682
+
1683
+ detail = turn.controller.detail
1684
+ by = detail[:by].to_s if detail.is_a?(Hash)
1685
+ by unless by.nil? || by.empty?
1686
+ end
1687
+ private :cancelled_by
1688
+
1646
1689
  # The turn's settings for the kernel (LLM::TurnSettings): vision,
1647
1690
  # sampling and thinking, the thinking resolved before the hooks run.
1648
1691
  # The model name is added in #generate, after the hooks that may switch
@@ -1744,7 +1787,7 @@ module Samagotchi
1744
1787
  end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
1745
1788
  kept = kept_messages(turn, result, seconds)
1746
1789
  if canceled
1747
- [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
1790
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
1748
1791
  duration_ms: (seconds * 1000).round }]
1749
1792
  else
1750
1793
  # For a client that attaches later (session_state_snapshot).
@@ -13,7 +13,7 @@ module Samagotchi
13
13
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
14
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
15
  # joining UI replays as live events.
16
- PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
16
+ PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
17
17
  # Types no Engine emits that a UI handles: the Bridge's stream frames
18
18
  # and the web's one synthetic replay event (a prompt merged into the
19
19
  # turn, turn_events.js snapshotEvents).
@@ -438,7 +438,7 @@ module Samagotchi
438
438
  record_context_status(response.usage, window)
439
439
  emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
440
440
  thinking_chars: response.reasoning.to_s.length, served_model: response.model,
441
- requested_model: @model_name, finish_reason: response.finish_reason)
441
+ served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason)
442
442
  dump_response(response, iteration)
443
443
  @loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
444
444
  messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
23
  # ConnectionRefused
24
24
  # RateLimited 429, with retry_after when the server says
25
+ # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
+ # requests; a RateLimited, retried after 20 s
27
+ # OutOfCredits any other 402; never retried
25
28
  # ServerError 5xx or a server's error event mid-stream
26
29
  # AuthError 401/403, or an API key variable that is not set
27
30
  # BadRequest other 4xx (a context overflow is one, whatever status;
@@ -84,6 +87,24 @@ module Samagotchi
84
87
  def default_retryable? = true
85
88
  end
86
89
 
90
+ # A 402 that says the account's credit is reserved by requests still in
91
+ # flight (OpenRouter: "This request would exceed your available credits
92
+ # given your current in-flight requests"): it clears once they settle,
93
+ # so it is retried like a 429, after CREDITS_SETTLE_DELAY unless the
94
+ # server says how long.
95
+ class CreditsHeld < RateLimited
96
+ def kind = :credits_held
97
+
98
+ def summary = "credits on host #{host} are held by in-flight requests (retry after #{retry_after.to_f.ceil}s)"
99
+ end
100
+
101
+ # Any other 402: the account has run out of credits. Never retried.
102
+ class OutOfCredits < ProviderError
103
+ def kind = :credits
104
+
105
+ def summary = "out of credits on host #{host}: #{detail}; add credits, then send again"
106
+ end
107
+
87
108
  class ServerError < ProviderError
88
109
  def kind = :server
89
110
 
@@ -238,6 +259,10 @@ module Samagotchi
238
259
  VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
239
260
  # A 400 about the thinking fields chi sent (Thinking.chat_fields).
240
261
  REASONING_REFUSED_RE = /reasoning|thinking/i
262
+ # A 402 about credit held by in-flight requests (see CreditsHeld).
263
+ CREDITS_HELD_RE = /in-flight requests/i
264
+ # Seconds to wait before retrying a CreditsHeld without a Retry-After.
265
+ CREDITS_SETTLE_DELAY = 20.0
241
266
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
242
267
 
243
268
  module_function
@@ -259,6 +284,12 @@ module Samagotchi
259
284
  when 401, 403 then AuthError.new(text, **options)
260
285
  when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
261
286
  when 408 then ServerError.new(text, retryable: true, **options)
287
+ when 402
288
+ if CREDITS_HELD_RE.match?(message) || CREDITS_HELD_RE.match?(body.to_s)
289
+ CreditsHeld.new(text, retry_after: parse_retry_after(retry_after) || CREDITS_SETTLE_DELAY, **options)
290
+ else
291
+ OutOfCredits.new(text, **options)
292
+ end
262
293
  when 400..499
263
294
  tools = TOOLS_UNSUPPORTED_RE.match?(message)
264
295
  BadRequest.new(text, tools_unsupported: tools,
@@ -6,6 +6,7 @@ require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
7
  require_relative "errors"
8
8
  require_relative "http"
9
+ require_relative "prompt_cache"
9
10
  require_relative "usage"
10
11
  require_relative "utf8_scrub"
11
12
 
@@ -35,10 +36,12 @@ module Samagotchi
35
36
  # ([] when none), usage (never nil) and the finish reason.
36
37
  # +model+ is the model the server says answered (the body's `model`),
37
38
  # which can differ from the one asked for; nil when it says nothing.
39
+ # +provider+ is the provider that served it (the body's `provider`,
40
+ # OpenRouter's routing name); nil when the server says nothing.
38
41
  # +cut+ is set on the empty response the chat loop makes for a
39
42
  # generation a plugin cut (stop_generation): {by:, reason:}.
40
- ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
41
- def initialize(model: nil, cut: nil, **fields) = super
43
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
44
+ def initialize(model: nil, provider: nil, cut: nil, **fields) = super
42
45
  end
43
46
 
44
47
  # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
@@ -119,7 +122,8 @@ module Samagotchi
119
122
  session_id: nil)
120
123
  body = request_body(messages, tools, model, options)
121
124
  request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
122
- log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
125
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
126
+ cache: ("on" if PromptCache.marked?(body[:messages])) }
123
127
  return chat_once(request, cancel_controller, log_fields) unless @stream
124
128
 
125
129
  assembly = Assembly.new
@@ -202,7 +206,7 @@ module Samagotchi
202
206
  def request_body(messages, tools, model, options)
203
207
  body = {
204
208
  model: model,
205
- messages: Array(messages).map { |message| wire_message(message) },
209
+ messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
206
210
  temperature: 0.0,
207
211
  stream: @stream
208
212
  }
@@ -215,6 +219,16 @@ module Samagotchi
215
219
  body.merge(options || {}).compact
216
220
  end
217
221
 
222
+ # Prompt-cache breakpoints (PromptCache) on the wire copies, for a
223
+ # Claude model's chat requests to a remote host: a one-shot side ask
224
+ # would pay for a cache write nothing reads, and a local model named
225
+ # after Claude must not get Array content.
226
+ def cache_breakpoints(messages, model)
227
+ return messages unless @remote && @purpose == "chat"
228
+
229
+ PromptCache.mark(messages, model: model)
230
+ end
231
+
218
232
  # The body's fields beyond the conversation and the stream, for the
219
233
  # log line: "temperature=0.6 presence_penalty=1.5".
220
234
  def sampling_summary(body)
@@ -248,7 +262,8 @@ module Samagotchi
248
262
  ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
249
263
  reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
250
264
  tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
251
- finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
265
+ finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
266
+ provider: self.class.served_provider(body))
252
267
  end
253
268
 
254
269
  # Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
@@ -265,6 +280,15 @@ module Samagotchi
265
280
 
266
281
  def served_model(payload) = self.class.served_model(payload)
267
282
 
283
+ # OpenRouter names the provider that served a request (Baseten,
284
+ # Fireworks, …) in the body's `provider`; other servers send none.
285
+ def self.served_provider(payload)
286
+ provider = payload.is_a?(Hash) ? payload["provider"] : nil
287
+ provider.is_a?(String) && !provider.strip.empty? ? provider : nil
288
+ end
289
+
290
+ def served_provider(payload) = self.class.served_provider(payload)
291
+
268
292
  # The parsed payload of a `data:` line; nil for blank lines, comments
269
293
  # and [DONE]. Error events raise their ProviderError.
270
294
  # A 200 whose body is a plain JSON error instead of a stream (OpenRouter
@@ -352,7 +376,7 @@ module Samagotchi
352
376
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
353
377
 
354
378
  # Streamed deltas put together: text, reasoning, tool calls by index,
355
- # the finish reason, the usage chunk and the served model.
379
+ # the finish reason, the usage chunk, the served model and provider.
356
380
  class Assembly
357
381
  # The chunk carries part of a tool call.
358
382
  def self.tool_call_delta?(payload)
@@ -367,12 +391,14 @@ module Samagotchi
367
391
  @finish_reason = nil
368
392
  @usage = nil
369
393
  @model = nil
394
+ @provider = nil
370
395
  end
371
396
 
372
397
  # @return [Array(String, String)] this chunk's content and reasoning
373
398
  def add(payload)
374
399
  @usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
375
400
  @model = OpenAIChat.served_model(payload) || @model
401
+ @provider = OpenAIChat.served_provider(payload) || @provider
376
402
  choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
377
403
  return ["", ""] unless choice.is_a?(Hash)
378
404
 
@@ -392,7 +418,7 @@ module Samagotchi
392
418
  ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
393
419
  end
394
420
  ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
395
- finish_reason: @finish_reason, model: @model)
421
+ finish_reason: @finish_reason, model: @model, provider: @provider)
396
422
  end
397
423
 
398
424
  private