samagotchi 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +10 -4
  5. data/docs/configuration.md +30 -13
  6. data/docs/hooks.md +21 -7
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +17 -3
  10. data/docs/sub-agent.md +4 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bridge/card_store.rb +4 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  14. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +7 -0
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  18. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  20. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  21. data/lib/samagotchi/command_steps.rb +233 -0
  22. data/lib/samagotchi/config.rb +9 -2
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  24. data/lib/samagotchi/engine.rb +103 -5
  25. data/lib/samagotchi/generation_phase.rb +83 -0
  26. data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
  27. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  28. data/lib/samagotchi/idle_client.rb +14 -2
  29. data/lib/samagotchi/idle_recap.rb +4 -2
  30. data/lib/samagotchi/kernel_loop.rb +40 -12
  31. data/lib/samagotchi/llm/chat_loop.rb +37 -13
  32. data/lib/samagotchi/llm/errors.rb +26 -1
  33. data/lib/samagotchi/llm/openai_chat.rb +8 -4
  34. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  35. data/lib/samagotchi/llm/usage.rb +18 -3
  36. data/lib/samagotchi/log_subscriber.rb +5 -3
  37. data/lib/samagotchi/plugin/api.rb +3 -2
  38. data/lib/samagotchi/prompt.rb +4 -1
  39. data/lib/samagotchi/session_metrics.rb +38 -4
  40. data/lib/samagotchi/steer.rb +103 -8
  41. data/lib/samagotchi/system_prompt.rb +30 -9
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  44. data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
  45. data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
  46. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  47. data/lib/samagotchi/terminal_ui.rb +5 -0
  48. data/lib/samagotchi/thinking_tails.rb +103 -0
  49. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  50. data/lib/samagotchi/token_usage.rb +18 -6
  51. data/lib/samagotchi/tool_activity.rb +37 -3
  52. data/lib/samagotchi/tool_declarations.rb +14 -0
  53. data/lib/samagotchi/tool_runner.rb +7 -0
  54. data/lib/samagotchi/tool_view.rb +76 -0
  55. data/lib/samagotchi/tools/builtins.rb +4 -1
  56. data/lib/samagotchi/tools/execute.rb +2 -1
  57. data/lib/samagotchi/tools/registry.rb +11 -4
  58. data/lib/samagotchi/tools/shell.rb +32 -0
  59. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  60. data/lib/samagotchi/turn_tally.rb +17 -6
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/waiting_steer.rb +64 -0
  63. data/lib/samagotchi/web/message_parts.rb +5 -0
  64. data/lib/samagotchi/web/public/activity.js +5 -0
  65. data/lib/samagotchi/web/public/app.js +2 -1
  66. data/lib/samagotchi/web/public/command_view.js +96 -0
  67. data/lib/samagotchi/web/public/copy.js +7 -0
  68. data/lib/samagotchi/web/public/format.js +11 -1
  69. data/lib/samagotchi/web/public/index.html +33 -0
  70. data/lib/samagotchi/web/public/stage_model.js +16 -5
  71. data/lib/samagotchi/web/public/stage_view.js +5 -2
  72. data/lib/samagotchi/web/public/turn_events.js +15 -1
  73. data/lib/samagotchi/web/public/turn_view.js +15 -6
  74. data/lib/samagotchi/worker.rb +4 -1
  75. metadata +9 -1
@@ -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.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:52fcb0a3beccfee1e70e7d73162eace603da11bee43e535b1bc17d55eae98334
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
- return false if role == "user" && kind != "steer"
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.2
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:27b135c462d8462313edaa1faa90f3104a1bff3c421d6965e9e71955f347d55b
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, "forget_after" => 10 }.freeze
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
- return (short if @thinking_chars >= @min_chars) if words.length < @min_words
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 (#{what})", level: :warn)
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 (#{what}): cut", level: :warn) if ctx.stop_generation("its thinking kept repeating 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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.23.0
3
+ version: 0.25.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:
@@ -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
@@ -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" (drops chi's own temperature default). Not a map: warns once,
666
- # nil. Reserved keys warn once and are dropped. nil when nothing is left.
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
- # Greedy decoding on a near-identical prompt would likely loop again:
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.
@@ -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
- @tools = Tools::Builtins.registry
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.