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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -1
- data/docs/architecture.md +5 -3
- data/docs/cli.md +2 -1
- data/docs/configuration.md +11 -0
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +11 -5
- data/lib/samagotchi/answer_command.rb +7 -3
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +6 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/continue_offer.rb +34 -9
- data/lib/samagotchi/engine.rb +47 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/errors.rb +31 -0
- data/lib/samagotchi/llm/openai_chat.rb +33 -7
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/question_desk.rb +1 -7
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +6 -4
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +34 -7
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/timing.js +5 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -0
- 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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
@
|
|
111
|
-
@
|
|
112
|
-
|
|
113
|
-
if
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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}"
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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).
|
data/lib/samagotchi/events.rb
CHANGED
|
@@ -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
|
|
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
|