samagotchi 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -1
- data/docs/architecture.md +16 -9
- data/docs/cli.md +10 -4
- data/docs/configuration.md +30 -13
- data/docs/hooks.md +21 -7
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/prompt-caching.md +94 -0
- data/docs/plugins.md +17 -3
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +12 -0
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/command_steps.rb +233 -0
- data/lib/samagotchi/config.rb +9 -2
- data/lib/samagotchi/empty_answer_retry.rb +2 -2
- data/lib/samagotchi/engine.rb +103 -5
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_client.rb +14 -2
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/chat_loop.rb +37 -13
- data/lib/samagotchi/llm/errors.rb +26 -1
- data/lib/samagotchi/llm/openai_chat.rb +8 -4
- data/lib/samagotchi/llm/prompt_cache.rb +34 -9
- data/lib/samagotchi/llm/usage.rb +18 -3
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +38 -4
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +30 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thinking_tails.rb +103 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/token_usage.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +37 -3
- data/lib/samagotchi/tool_declarations.rb +14 -0
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +76 -0
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/execute.rb +2 -1
- data/lib/samagotchi/tools/registry.rb +11 -4
- data/lib/samagotchi/tools/shell.rb +32 -0
- data/lib/samagotchi/tools/task_runtime.rb +3 -3
- data/lib/samagotchi/turn_tally.rb +17 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +96 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/format.js +11 -1
- data/lib/samagotchi/web/public/index.html +33 -0
- data/lib/samagotchi/web/public/stage_model.js +16 -5
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +9 -1
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "session"
|
|
|
20
20
|
require_relative "engine"
|
|
21
21
|
require_relative "guardrails/parent_approvals"
|
|
22
22
|
require_relative "session_commands"
|
|
23
|
+
require_relative "steer"
|
|
23
24
|
require_relative "image_store"
|
|
24
25
|
require_relative "log"
|
|
25
26
|
require_relative "version"
|
|
@@ -780,6 +781,8 @@ module Samagotchi
|
|
|
780
781
|
end
|
|
781
782
|
|
|
782
783
|
enqueued_id = SecureRandom.uuid
|
|
784
|
+
# Before it is queued: a drain after this may have taken it (#cut_for_steer).
|
|
785
|
+
epoch = @engine.input_epoch
|
|
783
786
|
# Write and announce with the event log held: the worker can't emit
|
|
784
787
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
785
788
|
# :turn_enqueued, and a failed write announces nothing.
|
|
@@ -799,6 +802,10 @@ module Samagotchi
|
|
|
799
802
|
return deadline_passed("turn") if enqueued == :expired
|
|
800
803
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
801
804
|
|
|
805
|
+
# Queued (outside the event lock): a running generation that has only
|
|
806
|
+
# been thinking for long is cut for it, unless a plugin sent it. An
|
|
807
|
+
# image input never merges mid-turn, so it never cuts.
|
|
808
|
+
@engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
|
|
802
809
|
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
|
|
803
810
|
end
|
|
804
811
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:821aea2821df12f278db9a94f8150244e67bf9453735d19a5abe9d94253eefa6
|
|
10
10
|
requires_chi: ">= 0.3.0"
|
|
@@ -147,7 +147,8 @@ class Plugin
|
|
|
147
147
|
role = (message[:role] || message["role"]).to_s
|
|
148
148
|
kind = (message[:kind] || message["kind"]).to_s
|
|
149
149
|
return true if kind == "steer" && (message[:content] || message["content"]).to_s.strip == text.strip
|
|
150
|
-
|
|
150
|
+
# A user line merged into the turn (kind input) is part of it: look past it.
|
|
151
|
+
return false if role == "user" && !%w[steer input].include?(kind)
|
|
151
152
|
end
|
|
152
153
|
false
|
|
153
154
|
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop-guard
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.3
|
|
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:8cf255cd1cc0846a759fcf70144af53bfc634c9d5c766981ce0ef5e476749e0a
|
|
10
10
|
requires_chi: ">= 0.6.0"
|
|
@@ -56,12 +56,20 @@ require "digest"
|
|
|
56
56
|
# too ("Hmm.", "Fine."), but spread out, or in runs of different ones
|
|
57
57
|
# (code lines); a sentence of min_words or more ends the run, one with no
|
|
58
58
|
# letters or digits neither counts nor ends it. Lines inside a ``` code
|
|
59
|
-
# block are not short sentences
|
|
59
|
+
# block are not short sentences;
|
|
60
|
+
# - a low-diversity window: among the last window_sentences sentences of any
|
|
61
|
+
# length, at most window_distinct different ones (exact, normalized). It
|
|
62
|
+
# sees a cycle too long for the short run (7-8 short sentences), or one
|
|
63
|
+
# where a longer sentence keeps ending the run ("Go. OK. I'll write the
|
|
64
|
+
# spec file now. Go."). Real thinking holds 40+ different sentences in
|
|
65
|
+
# any 48 (the lowest seen: 42); a loop holds under 10. Lines inside a ```
|
|
66
|
+
# code block don't count.
|
|
60
67
|
# Nothing triggers before min_chars of thinking. O(new chars) per feed.
|
|
61
68
|
class ThinkingWatch
|
|
62
69
|
DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
|
|
63
70
|
"similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
|
|
64
|
-
"min_words" => 5, "short_run" => 24, "short_distinct" => 6, "
|
|
71
|
+
"min_words" => 5, "short_run" => 24, "short_distinct" => 6, "window_sentences" => 48,
|
|
72
|
+
"window_distinct" => 12, "forget_after" => 10 }.freeze
|
|
65
73
|
ACTIONS = %w[retry stop notify].freeze
|
|
66
74
|
MAX_CARRY = 400
|
|
67
75
|
# One sentence again and again needs this much more alike than a cycle.
|
|
@@ -81,7 +89,7 @@ class ThinkingWatch
|
|
|
81
89
|
out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
|
|
82
90
|
out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
|
|
83
91
|
%w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words short_run
|
|
84
|
-
short_distinct].each do |key|
|
|
92
|
+
short_distinct window_sentences window_distinct].each do |key|
|
|
85
93
|
number = Integer(raw[key].to_s, exception: false)
|
|
86
94
|
out[key] = number if number&.positive?
|
|
87
95
|
end
|
|
@@ -104,6 +112,8 @@ class ThinkingWatch
|
|
|
104
112
|
@min_words = settings["min_words"]
|
|
105
113
|
@short_run = settings["short_run"]
|
|
106
114
|
@short_distinct = settings["short_distinct"]
|
|
115
|
+
@window_sentences = settings["window_sentences"]
|
|
116
|
+
@window_distinct = settings["window_distinct"]
|
|
107
117
|
@same_similarity = [@similarity + SAME_MARGIN, 0.95].min
|
|
108
118
|
@carry = +""
|
|
109
119
|
@window = [] # the last max_period + 1 sentences
|
|
@@ -112,6 +122,8 @@ class ThinkingWatch
|
|
|
112
122
|
@counts = Hash.new(0)
|
|
113
123
|
@short_count = 0 # short sentences in a row
|
|
114
124
|
@short_window = [] # the last short_run of them, [key, text]
|
|
125
|
+
@recent = [] # the last window_sentences sentences, [key, text]
|
|
126
|
+
@recent_counts = Hash.new(0) # key => times in @recent
|
|
115
127
|
@in_code = false
|
|
116
128
|
@thinking_chars = 0
|
|
117
129
|
end
|
|
@@ -153,7 +165,8 @@ class ThinkingWatch
|
|
|
153
165
|
end
|
|
154
166
|
words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
|
|
155
167
|
short = short_loop(text.strip, words) unless @in_code
|
|
156
|
-
|
|
168
|
+
window = window_loop(text.strip, words) unless @in_code
|
|
169
|
+
return (@thinking_chars >= @min_chars ? short || window : nil) if words.length < @min_words
|
|
157
170
|
|
|
158
171
|
key = words.join(" ")
|
|
159
172
|
sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
|
|
@@ -164,7 +177,27 @@ class ThinkingWatch
|
|
|
164
177
|
@window.shift if @window.length > @max_period + 1
|
|
165
178
|
return nil if @thinking_chars < @min_chars
|
|
166
179
|
|
|
167
|
-
cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
|
|
180
|
+
cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil) || window
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The last window_sentences sentences (any length) hold at most
|
|
184
|
+
# window_distinct different ones: a loop. Its sentences are the most
|
|
185
|
+
# frequent first.
|
|
186
|
+
def window_loop(text, words)
|
|
187
|
+
return nil if words.empty?
|
|
188
|
+
|
|
189
|
+
key = words.join(" ")
|
|
190
|
+
@recent << [key, text]
|
|
191
|
+
@recent_counts[key] += 1
|
|
192
|
+
if @recent.length > @window_sentences
|
|
193
|
+
old, = @recent.shift
|
|
194
|
+
@recent_counts.delete(old) if (@recent_counts[old] -= 1).zero?
|
|
195
|
+
end
|
|
196
|
+
return nil if @recent.length < @window_sentences || @recent_counts.size > @window_distinct
|
|
197
|
+
|
|
198
|
+
texts = @recent.to_h # key => its last text
|
|
199
|
+
top = @recent_counts.sort_by { |_key, n| -n }.first(3).map { |key, _n| texts[key] }
|
|
200
|
+
Loop.new(period: @recent_counts.size, times: @window_sentences / @recent_counts.size, sentences: top, chars: nil)
|
|
168
201
|
end
|
|
169
202
|
|
|
170
203
|
def short_loop(text, words)
|
|
@@ -276,14 +309,17 @@ class Plugin
|
|
|
276
309
|
@watch_done = true
|
|
277
310
|
what = "#{found.period == 1 ? "one sentence" : "#{found.period} sentences"} ×#{found.times}, " \
|
|
278
311
|
"#{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
|
|
312
|
+
# The notices quote the loop's sentences on one line (a short cycle
|
|
313
|
+
# whole), as the stop card does.
|
|
314
|
+
quoted = "\"#{cut(found.sentences.first(3).join(" ").gsub(/\s+/, " "))}\", #{what}"
|
|
279
315
|
case @thinking["action"]
|
|
280
316
|
when "notify"
|
|
281
|
-
ctx.notify("thinking repeats itself (#{
|
|
317
|
+
ctx.notify("thinking repeats itself (#{quoted})", level: :warn)
|
|
282
318
|
when "retry"
|
|
283
319
|
@thinking_loops += 1
|
|
284
320
|
return stop_thinking(ctx, found, what) if @thinking_loops > 1
|
|
285
321
|
|
|
286
|
-
ctx.notify("thinking repeats itself (#{
|
|
322
|
+
ctx.notify("thinking repeats itself (#{quoted}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating itself")
|
|
287
323
|
else
|
|
288
324
|
stop_thinking(ctx, found, what)
|
|
289
325
|
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/shell_lex"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# An execute command as the steps a person reads, for the web's tool row
|
|
7
|
+
# (display only: the guardrails read the command themselves). Built from
|
|
8
|
+
# ShellLex's tokens and their spans, so each step's text is a slice of the
|
|
9
|
+
# command as the model wrote it. Rules, in order:
|
|
10
|
+
#
|
|
11
|
+
# - a heredoc's body leaves its step's text and becomes its +heredoc+
|
|
12
|
+
# ({tag:, lines:}), also one inside a "$(cat <<'EOF' … EOF)" argument;
|
|
13
|
+
# - steps are split at && || ; | |& & and newlines; a ( … ) subshell stays
|
|
14
|
+
# one step; +op+ is the operator before a step (nil for the first);
|
|
15
|
+
# - a leading "cd X &&" / "cd X;" becomes +cd+ (only the first);
|
|
16
|
+
# - a pipeline's last "| head …" / "| tail …" becomes the +limit+ of the
|
|
17
|
+
# step before it ("head 20", "tail", "head -c 60");
|
|
18
|
+
# - a lone echo of a marker-wrapped string ("=== x ===", "--- x ---",
|
|
19
|
+
# "## x") becomes the +label+ of the next step; a bare marker echo
|
|
20
|
+
# ("---", "====", "") only spaces the output and goes;
|
|
21
|
+
# - anything the parser isn't sure of (a quote or heredoc left open,
|
|
22
|
+
# for/while/until/if/case/function bodies, { … } groups, unbalanced
|
|
23
|
+
# parentheses) gives nil: the UI then shows the title and the raw
|
|
24
|
+
# command.
|
|
25
|
+
module CommandSteps
|
|
26
|
+
Step = Data.define(:text, :op, :label, :limit, :heredoc) do
|
|
27
|
+
def initialize(text:, op: nil, label: nil, limit: nil, heredoc: nil) = super
|
|
28
|
+
|
|
29
|
+
def to_h = super.compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Parsed = Data.define(:cd, :steps) do
|
|
33
|
+
def to_h = { cd: cd, steps: steps.map(&:to_h) }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Lex = Guardrails::ShellLex
|
|
37
|
+
|
|
38
|
+
SPLIT = ["&&", "||", ";", "|", "|&", "&", "\n"].freeze
|
|
39
|
+
KEYWORDS = %w[for while until if then elif else fi case esac select do done function { }].freeze
|
|
40
|
+
LIMITERS = %w[head tail].freeze
|
|
41
|
+
MARKER = /\A\s*(?:={2,}|-{2,}|#+)\s*(.*?)\s*(?:={2,}|-{2,})?\s*\z/m
|
|
42
|
+
ECHO_FLAGS = %w[-e -n -en -ne].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @return [Parsed, nil] nil when the command isn't plain enough to show
|
|
47
|
+
# as steps
|
|
48
|
+
def parse(command)
|
|
49
|
+
text = command.to_s
|
|
50
|
+
lexer = Lex::Lexer.new(text)
|
|
51
|
+
tokens = lexer.tokens.zip(lexer.spans).map { |token, span| [*token, span] }
|
|
52
|
+
return nil if lexer.unterminated? || tokens.empty?
|
|
53
|
+
|
|
54
|
+
bodies = lexer.heredoc_spans.to_h { |doc| [doc[:span], doc[:tag]] }
|
|
55
|
+
raw = split(tokens)
|
|
56
|
+
return nil if raw.nil? || raw.empty?
|
|
57
|
+
|
|
58
|
+
steps = raw.map { |op, words| step(text, op, words, bodies) }
|
|
59
|
+
return nil if steps.any?(&:nil?)
|
|
60
|
+
|
|
61
|
+
cd, steps = leading_cd(steps, raw)
|
|
62
|
+
steps = labels(limits(steps))
|
|
63
|
+
steps.empty? ? nil : Parsed.new(cd: cd, steps: steps)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# [[op, word tokens]] split at SPLIT outside parentheses; nil for a
|
|
67
|
+
# control keyword at a command's start, ;; or unbalanced parentheses.
|
|
68
|
+
def split(tokens)
|
|
69
|
+
steps = []
|
|
70
|
+
op = nil
|
|
71
|
+
words = []
|
|
72
|
+
depth = 0
|
|
73
|
+
tokens.each do |token|
|
|
74
|
+
kind, value = token
|
|
75
|
+
if kind == :op && value == "("
|
|
76
|
+
depth += 1
|
|
77
|
+
words << token
|
|
78
|
+
elsif kind == :op && value == ")"
|
|
79
|
+
depth -= 1
|
|
80
|
+
return nil if depth.negative?
|
|
81
|
+
|
|
82
|
+
words << token
|
|
83
|
+
elsif kind == :op && depth.zero?
|
|
84
|
+
return nil unless SPLIT.include?(value)
|
|
85
|
+
|
|
86
|
+
if words.empty?
|
|
87
|
+
op = value unless value == "\n" # "a;\nb" keeps the ;
|
|
88
|
+
else
|
|
89
|
+
steps << [steps.empty? ? nil : op, words]
|
|
90
|
+
op = value
|
|
91
|
+
end
|
|
92
|
+
words = []
|
|
93
|
+
else
|
|
94
|
+
return nil if keyword?(words, token)
|
|
95
|
+
|
|
96
|
+
words << token
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
return nil unless depth.zero?
|
|
100
|
+
|
|
101
|
+
steps << [steps.empty? ? nil : op, words] unless words.empty?
|
|
102
|
+
steps
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether +token+ is a control keyword where a command starts (the
|
|
106
|
+
# step's first word, or the first after a "(" inside it).
|
|
107
|
+
def keyword?(words, token)
|
|
108
|
+
return false unless token[0] == :word
|
|
109
|
+
|
|
110
|
+
at_start = words.empty? || words.last[0] == :op
|
|
111
|
+
at_start && KEYWORDS.include?(token[1])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A Step from its tokens: the text from the first to the last token,
|
|
115
|
+
# heredoc bodies cut out.
|
|
116
|
+
def step(text, op, tokens, bodies)
|
|
117
|
+
body = tokens.find { |t| bodies.key?(t.last) }
|
|
118
|
+
shown = tokens.reject { |t| bodies.key?(t.last) }
|
|
119
|
+
from = shown.map { |t| t.last.begin }.min
|
|
120
|
+
to = shown.map { |t| t.last.end }.max
|
|
121
|
+
return nil unless from
|
|
122
|
+
|
|
123
|
+
slice = text[from...to]
|
|
124
|
+
heredoc = body && { tag: bodies[body.last], lines: body_lines(text[body.last]) }
|
|
125
|
+
slice, heredoc = inner_heredoc(slice) unless heredoc
|
|
126
|
+
Step.new(text: slice.strip, op: op, heredoc: heredoc)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A heredoc inside a $(…) in +slice+ (git commit -m "$(cat <<'EOF' …)"):
|
|
130
|
+
# the slice with the body cut out, and the heredoc; or the slice as is.
|
|
131
|
+
def inner_heredoc(slice)
|
|
132
|
+
return [slice, nil] unless slice.include?("<<")
|
|
133
|
+
|
|
134
|
+
at = 0
|
|
135
|
+
while (open = slice.index("$(", at))
|
|
136
|
+
lexer = Lex::Lexer.new(slice[(open + 2)..])
|
|
137
|
+
lexer.tokens
|
|
138
|
+
doc = lexer.heredoc_spans.first
|
|
139
|
+
if doc
|
|
140
|
+
from = open + 2 + doc[:span].begin - 1 # the newline before the body
|
|
141
|
+
to = open + 2 + doc[:span].end
|
|
142
|
+
to += 1 if slice[to] == "\n"
|
|
143
|
+
return [slice[0...from] + slice[to..], { tag: doc[:tag], lines: body_lines(slice[(from + 1)...to]) }]
|
|
144
|
+
end
|
|
145
|
+
at = open + 2
|
|
146
|
+
end
|
|
147
|
+
[slice, nil]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The body's line count, its terminator line not counted.
|
|
151
|
+
def body_lines(body_and_tag) = [body_and_tag.to_s.chomp.lines.size - 1, 0].max
|
|
152
|
+
|
|
153
|
+
# A first "cd X" step followed by && or ; becomes the cd.
|
|
154
|
+
def leading_cd(steps, raw)
|
|
155
|
+
words = raw.first[1]
|
|
156
|
+
next_op = steps[1]&.op
|
|
157
|
+
plain = words.size == 2 && words.all? { |t| t[0] == :word } && !words[1][1].include?(Lex::SUBST)
|
|
158
|
+
return [nil, steps] unless plain && words[0][1] == "cd" && ["&&", ";"].include?(next_op)
|
|
159
|
+
|
|
160
|
+
[words[1][1], [steps[1].with(op: nil), *steps.drop(2)]]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A pipeline's last head/tail becomes the limit of the step before it.
|
|
164
|
+
def limits(steps)
|
|
165
|
+
steps.each_with_index.with_object([]) do |(step, i), out|
|
|
166
|
+
limit = step.op == "|" && !out.empty? && out.last.limit.nil? && limit_of(step.text)
|
|
167
|
+
if limit && !["|", "|&"].include?(steps[i + 1]&.op)
|
|
168
|
+
out[-1] = out.last.with(limit: limit)
|
|
169
|
+
else
|
|
170
|
+
out << step
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# "head 20" for "head -n 20" / "head -20" / "head -n20"; "head -c 60";
|
|
176
|
+
# "tail +5"; "head" alone. nil for anything else (tail -f, a file).
|
|
177
|
+
def limit_of(text)
|
|
178
|
+
words = Lex.lex(text)
|
|
179
|
+
return nil unless words.all? { |t| t[0] == :word }
|
|
180
|
+
|
|
181
|
+
name, *args = words.map { |t| t[1] }
|
|
182
|
+
return nil unless LIMITERS.include?(name)
|
|
183
|
+
|
|
184
|
+
count = case args
|
|
185
|
+
in [] then nil
|
|
186
|
+
in [/\A-\d+\z/ => arg] then arg[1..]
|
|
187
|
+
in [/\A-n\+?\d+\z/ => arg] then arg[2..]
|
|
188
|
+
in ["-n", /\A\+?\d+\z/ => arg] then arg
|
|
189
|
+
in ["-c", /\A\d+\z/ => arg] then "-c #{arg}"
|
|
190
|
+
else return nil
|
|
191
|
+
end
|
|
192
|
+
[name, count].compact.join(" ")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# A marker echo labels the next step; a bare one goes.
|
|
196
|
+
# A dropped echo's op goes to the step after it (the link from the
|
|
197
|
+
# step before).
|
|
198
|
+
def labels(steps)
|
|
199
|
+
out = []
|
|
200
|
+
label = nil
|
|
201
|
+
op = nil
|
|
202
|
+
steps.each_with_index do |step, i|
|
|
203
|
+
marker = marker_label(step.text)
|
|
204
|
+
if marker.nil? || (!marker.empty? && i == steps.size - 1)
|
|
205
|
+
step = step.with(label: label, op: op || step.op) if op
|
|
206
|
+
out << (out.empty? ? step.with(op: nil) : step)
|
|
207
|
+
label = op = nil
|
|
208
|
+
else
|
|
209
|
+
label = marker unless marker.empty?
|
|
210
|
+
op ||= step.op || "\n"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The label of a lone marker echo ("" for a bare marker or an empty
|
|
217
|
+
# echo), nil for any other step.
|
|
218
|
+
def marker_label(text)
|
|
219
|
+
words = Lex.lex(text)
|
|
220
|
+
return nil unless words.all? { |t| t[0] == :word && !t[1].include?(Lex::SUBST) }
|
|
221
|
+
|
|
222
|
+
name, *args = words.map { |t| t[1] }
|
|
223
|
+
args.shift while ECHO_FLAGS.include?(args.first)
|
|
224
|
+
return nil unless name == "echo" && args.size <= 1
|
|
225
|
+
return "" if args.empty? || args[0].strip.empty?
|
|
226
|
+
|
|
227
|
+
match = MARKER.match(args[0])
|
|
228
|
+
return nil unless match && args[0].strip.match?(/\A(?:={2,}|-{2,}|#)/)
|
|
229
|
+
|
|
230
|
+
match[1].strip
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -144,6 +144,10 @@ module Samagotchi
|
|
|
144
144
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
145
145
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
146
146
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
147
|
+
# Seconds a generation must have streamed only thinking before a message
|
|
148
|
+
# for the running turn (the user's, chi send's, a parent agent's) cuts
|
|
149
|
+
# it (Engine#cut_for_steer); 0 = never. No CLI flag: workers get no CLI args.
|
|
150
|
+
Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
|
|
147
151
|
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
148
152
|
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
149
153
|
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
|
|
@@ -170,6 +174,8 @@ module Samagotchi
|
|
|
170
174
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
171
175
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
172
176
|
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
177
|
+
# Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
|
|
178
|
+
Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
|
|
173
179
|
|
|
174
180
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
175
181
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
@@ -662,8 +668,9 @@ module Samagotchi
|
|
|
662
668
|
# A `sampling:` setting (hosts entry or models: entry): request parameters
|
|
663
669
|
# passed through to the provider as written (keys symbolized, nested maps
|
|
664
670
|
# too, so e.g. chat_template_kwargs works). A null value means "don't
|
|
665
|
-
# send it" (
|
|
666
|
-
#
|
|
671
|
+
# send it" (a null temperature also keeps the empty-answer retry from
|
|
672
|
+
# setting one). Not a map: warns once, nil. Reserved keys warn once and
|
|
673
|
+
# are dropped. nil when nothing is left.
|
|
667
674
|
def sampling_map(value, where)
|
|
668
675
|
return nil if value.nil?
|
|
669
676
|
|
|
@@ -13,8 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# when the retries run out.
|
|
14
14
|
class EmptyAnswerRetry
|
|
15
15
|
MAX = 3
|
|
16
|
-
#
|
|
17
|
-
# the retry runs at this temperature unless one is configured.
|
|
16
|
+
# The same (or greedy) decoding on a near-identical prompt would likely
|
|
17
|
+
# loop again: the retry runs at this temperature unless one is configured.
|
|
18
18
|
TEMPERATURE = 0.6
|
|
19
19
|
# A generation cut at the provider's output cap (finish_reason length)
|
|
20
20
|
# is retried unless the context is this full.
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "session_commands"
|
|
|
22
22
|
require_relative "plugin/loader"
|
|
23
23
|
require_relative "log"
|
|
24
24
|
require_relative "log_subscriber"
|
|
25
|
+
require_relative "thinking_tails"
|
|
25
26
|
require_relative "client"
|
|
26
27
|
require_relative "host_registry"
|
|
27
28
|
require_relative "llm/backend"
|
|
@@ -47,6 +48,8 @@ require_relative "tools/memory"
|
|
|
47
48
|
require_relative "muted_memories"
|
|
48
49
|
require_relative "used_memories"
|
|
49
50
|
require_relative "turn_state"
|
|
51
|
+
require_relative "generation_phase"
|
|
52
|
+
require_relative "waiting_steer"
|
|
50
53
|
require_relative "bundle_needs"
|
|
51
54
|
require_relative "model_overlay"
|
|
52
55
|
require_relative "served_model"
|
|
@@ -103,6 +106,10 @@ module Samagotchi
|
|
|
103
106
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
104
107
|
# may steer or ask whether a turn runs while it loads.
|
|
105
108
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
109
|
+
# What the running generation streams: whether a steer may cut it.
|
|
110
|
+
@generation_phase = GenerationPhase.new(clock: -> { monotonic_now })
|
|
111
|
+
# A message that may cut it but came too early: it cuts later.
|
|
112
|
+
@waiting_steer = WaitingSteer.new
|
|
106
113
|
@scratch = scratch
|
|
107
114
|
@no_interrupt = no_interrupt
|
|
108
115
|
@chat_backend = nil
|
|
@@ -166,8 +173,9 @@ module Samagotchi
|
|
|
166
173
|
@hooks = load_hooks_from_config
|
|
167
174
|
load_hooks_from_bundles
|
|
168
175
|
# The tools this session offers (the prompts' declarations and the
|
|
169
|
-
# kernel's dispatch): the built-ins, per Engine.
|
|
170
|
-
|
|
176
|
+
# kernel's dispatch): the built-ins, per Engine. execute.description
|
|
177
|
+
# off: execute offers no description parameter.
|
|
178
|
+
@tools = Tools::Builtins.registry(command_description: Config.get("execute.description") != false)
|
|
171
179
|
if @scratch
|
|
172
180
|
# A scratch session's children would outlive it, and so would a
|
|
173
181
|
# note it left in a peer; its memories would too (write and edit
|
|
@@ -276,6 +284,10 @@ module Samagotchi
|
|
|
276
284
|
@session_observer.subscribe(observer: @metrics)
|
|
277
285
|
# And the event trail in the debug log (`turn` records).
|
|
278
286
|
@session_observer.subscribe(observer: LogSubscriber.new(session_id: -> { @session&.id }))
|
|
287
|
+
# The thinking of cut and capped generations, kept in the session dir.
|
|
288
|
+
@session_observer.subscribe(observer: ThinkingTails.new(session_dir: lambda {
|
|
289
|
+
@session&.id && Session.session_dir(@session.id, state_dir: session_state_dir)
|
|
290
|
+
}))
|
|
279
291
|
end
|
|
280
292
|
|
|
281
293
|
# A monotonically-increasing clock (wall clock can jump backwards; the idle
|
|
@@ -325,6 +337,69 @@ module Samagotchi
|
|
|
325
337
|
@turn_state.steer(text, source: source)
|
|
326
338
|
end
|
|
327
339
|
|
|
340
|
+
# A message for the running turn from +source+ (Steer.source_for_client:
|
|
341
|
+
# nil for the user, "chi_send", "parent_agent") cuts the streaming
|
|
342
|
+
# generation when it has streamed only thinking for steer.cut_after
|
|
343
|
+
# seconds (GenerationPhase): the loops' cut path then starts the step
|
|
344
|
+
# again with the message, no nudge, no retry spent. Too early, the
|
|
345
|
+
# message waits (WaitingSteer) and cuts once the thinking passes it
|
|
346
|
+
# (#recheck_waiting_steer, on each thinking chunk), unless a boundary
|
|
347
|
+
# hands it to the model first. A plugin's message never cuts.
|
|
348
|
+
# Call it after the message is queued, with the #input_epoch read
|
|
349
|
+
# before queueing it. Outside any lock; a cut that lands just after the
|
|
350
|
+
# generation ended is harmless (the loop re-asks).
|
|
351
|
+
# @return [Boolean] whether a generation was cut now
|
|
352
|
+
def cut_for_steer(source, epoch: nil)
|
|
353
|
+
return false unless Steer.cuts?(source)
|
|
354
|
+
|
|
355
|
+
after = Config.get("steer.cut_after").to_i
|
|
356
|
+
return false unless after.positive?
|
|
357
|
+
|
|
358
|
+
ctrl = active_cancel_controller
|
|
359
|
+
return false unless ctrl
|
|
360
|
+
return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
|
|
361
|
+
|
|
362
|
+
Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
|
|
363
|
+
false
|
|
364
|
+
rescue StandardError
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
369
|
+
# queueing a message for #cut_for_steer.
|
|
370
|
+
def input_epoch
|
|
371
|
+
@waiting_steer.epoch
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
375
|
+
# passes steer.cut_after.
|
|
376
|
+
def recheck_waiting_steer
|
|
377
|
+
return unless @waiting_steer.waiting?
|
|
378
|
+
|
|
379
|
+
after = Config.get("steer.cut_after").to_i
|
|
380
|
+
return unless after.positive? && @generation_phase.cuttable?(after)
|
|
381
|
+
|
|
382
|
+
source = @waiting_steer.take
|
|
383
|
+
ctrl = active_cancel_controller
|
|
384
|
+
steer_cut!(ctrl, source, waited: true) if source && ctrl
|
|
385
|
+
rescue StandardError
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
private :recheck_waiting_steer
|
|
389
|
+
|
|
390
|
+
def steer_cut!(ctrl, source, waited: false)
|
|
391
|
+
age = @generation_phase.age
|
|
392
|
+
cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
|
|
393
|
+
if cut
|
|
394
|
+
@waiting_steer.clear!
|
|
395
|
+
fields = { source: source, age: age&.round(1) }
|
|
396
|
+
fields[:waited] = true if waited
|
|
397
|
+
Log.info(:turn, "steer_cut", **fields)
|
|
398
|
+
end
|
|
399
|
+
cut
|
|
400
|
+
end
|
|
401
|
+
private :steer_cut!
|
|
402
|
+
|
|
328
403
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
329
404
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
330
405
|
# any thread; a turn that never begins keeps it for the next one, unless
|
|
@@ -344,10 +419,13 @@ module Samagotchi
|
|
|
344
419
|
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
345
420
|
# boundary the steers are dropped: the model answered, and a nudge would
|
|
346
421
|
# only restart the turn. The steer part fails on its own, never taking
|
|
347
|
-
# the user's lines with it.
|
|
422
|
+
# the user's lines with it. Items are a String (a user line), a
|
|
423
|
+
# Steer::Line (a worker's input line with its sender) or a steer Hash.
|
|
348
424
|
def turn_drain(pending_input)
|
|
349
425
|
lambda do |at_answer: false|
|
|
350
426
|
lines = pending_input ? Array(pending_input.call) : []
|
|
427
|
+
# The input went to the model: a message that waited to cut is in.
|
|
428
|
+
@waiting_steer.delivered! unless lines.empty?
|
|
351
429
|
lines + take_steers(at_answer)
|
|
352
430
|
end
|
|
353
431
|
end
|
|
@@ -1883,6 +1961,8 @@ module Samagotchi
|
|
|
1883
1961
|
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1884
1962
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1885
1963
|
left = @turn_state.finish!
|
|
1964
|
+
@generation_phase.finished!
|
|
1965
|
+
@waiting_steer.clear!
|
|
1886
1966
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
1887
1967
|
# The next turn's window and served model are asked again: the cache
|
|
1888
1968
|
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
@@ -1965,12 +2045,23 @@ module Samagotchi
|
|
|
1965
2045
|
nil
|
|
1966
2046
|
end
|
|
1967
2047
|
|
|
2048
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
2049
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
2050
|
+
# with thinking off), true once a real thought came.
|
|
2051
|
+
def note_generation_thought(thinking)
|
|
2052
|
+
return if thinking.empty?
|
|
2053
|
+
|
|
2054
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
2055
|
+
end
|
|
2056
|
+
|
|
1968
2057
|
# Thinking off, and the model thought anyway: logged each time, said
|
|
1969
|
-
# once per session and host.
|
|
2058
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
2059
|
+
# thought.
|
|
1970
2060
|
def check_thinking_honoured(event)
|
|
1971
2061
|
level, target = @turn_thinking
|
|
1972
2062
|
chars = event[:thinking_chars].to_i
|
|
1973
2063
|
return unless level == :off && chars.positive? && target
|
|
2064
|
+
return if @generation_thought == false
|
|
1974
2065
|
|
|
1975
2066
|
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
1976
2067
|
thinking_notice_once(:not_honoured, target, :warn,
|
|
@@ -2110,7 +2201,7 @@ module Samagotchi
|
|
|
2110
2201
|
return @native_backend unless target.entry.chat?
|
|
2111
2202
|
|
|
2112
2203
|
@chat_backend_mutex.synchronize do
|
|
2113
|
-
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel)
|
|
2204
|
+
@chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
|
|
2114
2205
|
@chat_backend.adapter = @host_registry.adapter_for(target.entry)
|
|
2115
2206
|
@chat_backend.session_id = session&.id
|
|
2116
2207
|
@chat_backend
|
|
@@ -2331,11 +2422,18 @@ module Samagotchi
|
|
|
2331
2422
|
progress = nil
|
|
2332
2423
|
case event[:type]
|
|
2333
2424
|
when :generation_started
|
|
2425
|
+
@generation_phase.started!
|
|
2426
|
+
@generation_thought = nil
|
|
2334
2427
|
watch&.started(event[:iteration])
|
|
2335
2428
|
when :generation_chunk
|
|
2336
2429
|
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
2337
2430
|
text = event.key?(:text) ? event[:text] : event[:content]
|
|
2338
2431
|
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2432
|
+
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2433
|
+
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2434
|
+
note_generation_thought(progress[:thinking])
|
|
2435
|
+
when :generation_retrying then @generation_phase.retrying!
|
|
2436
|
+
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2339
2437
|
end
|
|
2340
2438
|
# The chat loop asked again without the thinking fields: a notice,
|
|
2341
2439
|
# not an event of its own.
|