samagotchi 0.57.0 → 0.59.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +40 -23
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +3 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +28 -3
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_profile.rb +14 -3
  24. data/lib/samagotchi/model_settings.rb +4 -2
  25. data/lib/samagotchi/process_group.rb +76 -3
  26. data/lib/samagotchi/redacted_inspect.rb +21 -6
  27. data/lib/samagotchi/send_command.rb +1 -1
  28. data/lib/samagotchi/session_commands.rb +19 -6
  29. data/lib/samagotchi/steer_cut.rb +12 -8
  30. data/lib/samagotchi/steer_cut_after.rb +69 -0
  31. data/lib/samagotchi/system_prompt.rb +5 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
  33. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  34. data/lib/samagotchi/terminal_ui.rb +15 -6
  35. data/lib/samagotchi/tool_activity.rb +18 -1
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/execute.rb +318 -70
  38. data/lib/samagotchi/tools/task_get.rb +2 -0
  39. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  40. data/lib/samagotchi/tools/task_wait.rb +23 -5
  41. data/lib/samagotchi/utf8_default.rb +1 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +8 -2
  44. data/lib/samagotchi/web/public/app.js +7 -6
  45. data/lib/samagotchi/web/public/index.html +20 -7
  46. data/lib/samagotchi/web/public/read_view.js +3 -1
  47. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  48. data/lib/samagotchi/web/public/ref_scan.js +11 -1
  49. data/lib/samagotchi/web/public/refs.js +271 -51
  50. data/lib/samagotchi/web/public/stage_view.js +21 -0
  51. data/lib/samagotchi/web/public/task_stop.js +25 -7
  52. data/lib/samagotchi/worker.rb +2 -2
  53. metadata +3 -1
@@ -11,13 +11,22 @@
11
11
  # (relative to the project), and a trailing output filter dropped (a final
12
12
  # 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
13
13
  # runs). Arguments stay: `bundle exec rspec` (the suite) is not
14
- # `bundle exec rspec spec/x_spec.rb`. Input that ends inside a quote, $(…)
14
+ # `bundle exec rspec spec/x_spec.rb`. Wrappers a model adds by habit are
15
+ # dropped at the start of each simple command (after &&, ||, ;, a newline,
16
+ # so after a leading `cd X &&` too): `time [-p]` and `timeout`/`gtimeout`
17
+ # with its options and duration. `X=…` assignments stay, sorted (they can
18
+ # pick the target or the mode: SPEC=…, RAILS_ENV=…, CI=… (jest/vitest
19
+ # watch without it)), except NO_COLOR, FORCE_COLOR and TERM, which never
20
+ # change what runs; a plain
21
+ # `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
22
+ # stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
15
23
  # or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
16
24
  # cat/tee read as data: ShellLex makes those one placeholder, so different
17
25
  # commands would share a key.
18
26
  #
19
- # after_tool_call records: timed out, or ran at least warn_ratio of the
20
- # limit -> slow; ran under half of it with exit 0 -> the entry is dropped
27
+ # after_tool_call records: timed out (killed at the limit), handed over as
28
+ # a task at the limit (Execute::HANDED_OVER), or ran at least warn_ratio of
29
+ # the limit -> slow; ran under half of it with exit 0 -> the entry is dropped
21
30
  # (a fast failure says nothing about the suite's length), unless a trailing
22
31
  # filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
23
32
  # and a `| head` can end it early. A stopped or
@@ -25,15 +34,23 @@
25
34
  # running it again in the same turn is let through (maybe it got faster),
26
35
  # and that run records the new truth. before_turn resets it.
27
36
  #
28
- # Store: <data_dir>/commands.json, a list of {project, command, slow_at,
29
- # limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
37
+ # Store: <data_dir>/commands.json, a list of {project, command (the key),
38
+ # shown, slow_at, limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
30
39
  # record counts at once), written whole (AtomicFile), entries older than
31
40
  # ttl_days dropped on write.
32
41
  #
33
42
  # The advice names how long a finished task of the same command took: the
34
43
  # project's tmp/tasks/*/task.json read as files (never TaskRuntime's
35
44
  # list_records, which mkdirs, runs ps and writes), the exit_code file's
36
- # mtime minus started_at (finished_at is when chi noticed).
45
+ # mtime minus started_at (finished_at is when chi noticed). The exit_code
46
+ # file is the record's exit_code_path (a handed-over execute keeps it in
47
+ # the state dir), else the one next to task.json.
48
+ #
49
+ # A `timeout N` over the execute limit is denied up front (no history
50
+ # needed) when execute.on_timeout is kill: execute stops it first. Under
51
+ # task (the default) it runs on as a task until timeout fires: nothing to
52
+ # say. Exit codes mean what they always do here: 124/137 (timeout's) are
53
+ # no sign of a slow command by themselves.
37
54
  #
38
55
  # Settings (config.yml, bundles: slow-commands:):
39
56
  # mode: deny deny | notify (warn the user, never deny)
@@ -46,6 +63,7 @@ require "json"
46
63
  require "pathname"
47
64
  require "time"
48
65
  require "samagotchi/atomic_file"
66
+ require "samagotchi/config"
49
67
  require "samagotchi/guardrails/shell_lex"
50
68
  require "samagotchi/tools/execute"
51
69
  require "samagotchi/tools/task_runtime"
@@ -55,10 +73,23 @@ class Plugin
55
73
  FILE = "commands.json"
56
74
  FILTERS = %w[tail head grep].freeze
57
75
  TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
76
+ # Still running at the limit, handed over as a task (chi >= 0.59.0).
77
+ HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
58
78
  STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
59
79
  LEX = Samagotchi::Guardrails::ShellLex
60
80
  SHORT_CHARS = 60
61
81
  WAIT_DEFAULT = 600
82
+ # Where a simple command starts: a wrapper there is dropped from the key.
83
+ STARTS = ["&&", "||", ";", "\n"].freeze
84
+ ASSIGNMENT = /\A[A-Za-z_][A-Za-z0-9_]*=/
85
+ # Assignments that never change what runs: not part of the key.
86
+ NEUTRAL_VARS = %w[NO_COLOR FORCE_COLOR TERM].freeze
87
+ TIMEOUTS = %w[timeout gtimeout].freeze
88
+ # timeout's options: those that take a value, and the flags.
89
+ TIMEOUT_VALUE_OPTIONS = %w[-s -k --signal --kill-after].freeze
90
+ TIMEOUT_FLAGS = %w[--foreground --preserve-status -v --verbose].freeze
91
+ DURATION = /\A(\d+(?:\.\d+)?|\.\d+)([smhd]?)\z/
92
+ UNIT_SECONDS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
62
93
 
63
94
  def initialize(settings = {})
64
95
  @mode = settings["mode"].to_s == "notify" ? :notify : :deny
@@ -87,6 +118,7 @@ class Plugin
87
118
  tokens = LEX.lex(text)
88
119
  return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
89
120
 
121
+ tokens = unwrap(tokens).first
90
122
  tokens = strip_filters(tokens)
91
123
  return nil if tokens.empty?
92
124
 
@@ -96,6 +128,95 @@ class Plugin
96
128
  words.join(" ")
97
129
  end
98
130
 
131
+ # The tokens without the wrappers at the start of each simple command
132
+ # (time, timeout), assignments sorted and the neutral ones dropped, and
133
+ # the seconds of every timeout dropped. A simple command left with no
134
+ # words (`NO_COLOR=1 && rspec`) goes with the operator after it, and a
135
+ # last one with the operator before it.
136
+ # @return [Array(Array, Array<Float>)]
137
+ def self.unwrap(tokens)
138
+ durations = []
139
+ out = []
140
+ segment = []
141
+ flush = lambda do
142
+ words, seconds = unwrap_command(segment)
143
+ durations.concat(seconds)
144
+ out.concat(words)
145
+ segment = []
146
+ !words.empty?
147
+ end
148
+ tokens.each do |token|
149
+ if token[0] == :op && STARTS.include?(token[1])
150
+ out << token if flush.call
151
+ else
152
+ segment << token
153
+ end
154
+ end
155
+ out.pop if !flush.call && out.last&.first == :op && STARTS.include?(out.last[1])
156
+ [out, durations]
157
+ end
158
+
159
+ # One simple command's tokens (it may hold a | or & further on: only its
160
+ # start is looked at).
161
+ def self.unwrap_command(tokens)
162
+ rest = tokens.dup
163
+ assignments = []
164
+ durations = []
165
+ loop do
166
+ assignments << rest.shift[1] while word?(rest.first) && rest.first[1].match?(ASSIGNMENT)
167
+ head = word?(rest.first) ? rest.first[1] : nil
168
+ # time -p only: another option (GNU time -v) is a different program.
169
+ if head == "time" && !(word?(rest[1]) && rest[1][1].start_with?("-") && rest[1][1] != "-p")
170
+ rest.shift
171
+ rest.shift if word?(rest.first) && rest.first[1] == "-p"
172
+ elsif head == "env" && word?(rest[1]) && !rest[1][1].start_with?("-")
173
+ rest.shift
174
+ elsif TIMEOUTS.include?(head) && (skipped = timeout_length(rest))
175
+ count, seconds = skipped
176
+ rest.shift(count)
177
+ durations << seconds
178
+ else
179
+ break
180
+ end
181
+ end
182
+ kept = assignments.reject { |a| NEUTRAL_VARS.include?(a.split("=", 2).first) }.sort
183
+ [kept.map { |a| [:word, a, ""] } + rest, durations]
184
+ end
185
+
186
+ # [words to drop, seconds] for a `timeout [opts] DUR` at the start of
187
+ # +tokens+, or nil when it can't be read (then it stays in the key).
188
+ def self.timeout_length(tokens)
189
+ i = 1
190
+ while word?(tokens[i]) && tokens[i][1].start_with?("-")
191
+ option = tokens[i][1]
192
+ if TIMEOUT_FLAGS.include?(option) || option.start_with?("--signal=", "--kill-after=")
193
+ i += 1
194
+ elsif TIMEOUT_VALUE_OPTIONS.include?(option)
195
+ i += 2
196
+ else
197
+ return nil
198
+ end
199
+ end
200
+ seconds = word?(tokens[i]) ? duration_seconds(tokens[i][1]) : nil
201
+ seconds && word?(tokens[i + 1]) ? [i + 1, seconds] : nil
202
+ end
203
+
204
+ def self.duration_seconds(text)
205
+ match = DURATION.match(text)
206
+ match ? Float(match[1]) * UNIT_SECONDS.fetch(match[2]) : nil
207
+ end
208
+
209
+ def self.word?(token) = token && token[0] == :word
210
+
211
+ # The longest `timeout N` (seconds) at the start of a simple command in
212
+ # +text+, or nil for none (or text that can't be keyed).
213
+ def self.timeout_seconds(text)
214
+ text = text.to_s.strip
215
+ return nil if text.empty? || LEX.unterminated?(text)
216
+
217
+ unwrap(LEX.lex(text)).last.max
218
+ end
219
+
99
220
  # Whether the key dropped a trailing output filter (2>&1, | tail …):
100
221
  # the exit code is then the filter's, and the run may have been cut short.
101
222
  def self.filtered?(text)
@@ -148,18 +269,44 @@ class Plugin
148
269
  key = key_for(call, ctx)
149
270
  return unless key
150
271
  return if @denied[key]
272
+ return if over_limit_timeout(call, verdict, ctx, key)
151
273
 
152
274
  entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
153
275
  return unless entry
154
276
 
155
277
  @denied[key] = true
156
278
  if @mode == :notify
157
- ctx.notify("execute #{short(key[1])}: #{what(entry)} before", level: :warn)
279
+ ctx.notify("execute #{short(call[:content])}: #{what(entry)} before", level: :warn)
158
280
  return
159
281
  end
160
282
 
161
283
  verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
162
- ctx.notify("execute #{short(key[1])}: #{what(entry)} before, denied", level: :warn)
284
+ ctx.notify("execute #{short(call[:content])}: #{what(entry)} before, denied", level: :warn)
285
+ end
286
+
287
+ # A `timeout N` over the limit while execute kills at it (on_timeout:
288
+ # kill): N never takes effect. Denied once a turn, like a slow entry.
289
+ # @return [Boolean] whether it was handled
290
+ def over_limit_timeout(call, verdict, ctx, key)
291
+ return false unless Samagotchi::Config.get("execute.on_timeout").to_s == "kill"
292
+
293
+ seconds = self.class.timeout_seconds(call[:content])
294
+ limit = Samagotchi::Tools::Execute.timeout_seconds
295
+ return false unless seconds && seconds > limit
296
+
297
+ @denied[key] = true
298
+ wait = [WAIT_DEFAULT, seconds.ceil].max
299
+ line = "execute #{short(call[:content])}: timeout #{duration(seconds.ceil)} is past the #{limit} s limit"
300
+ if @mode == :notify
301
+ ctx.notify(line, level: :warn)
302
+ else
303
+ verdict.deny!("timeout past the execute limit", source: SOURCE,
304
+ advice: "execute stops a command at #{limit} s, so its timeout " \
305
+ "(#{duration(seconds.ceil)}) never takes effect. Start it " \
306
+ "with task_create, then task_wait (timeout #{wait}).")
307
+ ctx.notify("#{line}, denied", level: :warn)
308
+ end
309
+ true
163
310
  end
164
311
 
165
312
  def after(event, ctx)
@@ -174,10 +321,11 @@ class Plugin
174
321
  return unless key
175
322
 
176
323
  limit = Samagotchi::Tools::Execute.timeout_seconds
177
- if (timed_out = result[TIMED_OUT, 1])
178
- remember(ctx, key, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
324
+ shown = call[:content].to_s.strip
325
+ if (timed_out = result[TIMED_OUT, 1] || result[HANDED_OVER, 1])
326
+ remember(ctx, key, shown, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
179
327
  elsif elapsed_ms >= @warn_ratio * limit * 1000
180
- remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
328
+ remember(ctx, key, shown, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
181
329
  elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
182
330
  forget_key(ctx, key)
183
331
  end
@@ -218,9 +366,10 @@ class Plugin
218
366
  Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
219
367
  end
220
368
 
221
- def remember(ctx, key, limit_s:, elapsed_s:, timed_out:)
369
+ # +shown+: the command as this run typed it (the list shows it).
370
+ def remember(ctx, key, shown, limit_s:, elapsed_s:, timed_out:)
222
371
  list = entries(ctx).reject { |e| e["project"] == key[0] && e["command"] == key[1] }
223
- list << { "project" => key[0], "command" => key[1], "slow_at" => Time.now.utc.iso8601,
372
+ list << { "project" => key[0], "command" => key[1], "shown" => shown, "slow_at" => Time.now.utc.iso8601,
224
373
  "limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
225
374
  save(ctx, list)
226
375
  end
@@ -259,7 +408,8 @@ class Plugin
259
408
  # [started_at, seconds] for a task record of +key+ that ended with exit 0.
260
409
  def finished_task(path, ctx, key)
261
410
  record = JSON.parse(File.read(path))
262
- exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME)
411
+ exit_code = record["exit_code_path"].to_s
412
+ exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME) unless File.absolute_path?(exit_code)
263
413
  return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
264
414
 
265
415
  command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
@@ -282,7 +432,7 @@ class Plugin
282
432
  return "slow-commands: none recorded for #{project}" if mine.empty?
283
433
 
284
434
  lines = mine.each_with_index.map do |e, i|
285
- "#{i + 1}. #{e["command"]}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
435
+ "#{i + 1}. #{shown(e)}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
286
436
  end
287
437
  "slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
288
438
  when /\Aforget\s+(all|\d+)\z/
@@ -291,7 +441,7 @@ class Plugin
291
441
  return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
292
442
 
293
443
  save(ctx, entries(ctx).reject { |e| drop.any? { |d| d["project"] == e["project"] && d["command"] == e["command"] } })
294
- "slow-commands: forgot #{drop.size == 1 ? drop.first["command"] : "#{drop.size} commands"}"
444
+ "slow-commands: forgot #{drop.size == 1 ? shown(drop.first) : "#{drop.size} commands"}"
295
445
  else
296
446
  "usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
297
447
  end
@@ -299,6 +449,9 @@ class Plugin
299
449
 
300
450
  # --- text ---
301
451
 
452
+ # The command as last typed; an entry from 0.1.0 has only its key.
453
+ def shown(entry) = entry["shown"].to_s.empty? ? entry["command"] : entry["shown"]
454
+
302
455
  def what(entry)
303
456
  entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
304
457
  end
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
121
121
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
122
122
  2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
123
123
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
124
- 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
+ 4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
125
125
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
126
126
  6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
127
127
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.57.0
3
+ version: 0.59.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
9
+ config_modification_protocol.md: sha256:44a35f59baf44202972c9250989daa50eb978ab2a1967159e622c74ddd9c736c
10
10
  memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -20,6 +20,8 @@ module Samagotchi
20
20
  # One models: entry, and one field found in them; they need ConfigFile's readers.
21
21
  autoload :ModelSettings, File.expand_path("model_settings", __dir__)
22
22
  autoload :ModelSetting, File.expand_path("model_settings", __dir__)
23
+ # The per-model/host seconds a cut waits (like Thinking for the level).
24
+ autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
23
25
 
24
26
  # Unified configuration registry implementing the implicit convention:
25
27
  # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
@@ -216,7 +218,8 @@ module Samagotchi
216
218
  Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
217
219
  # Seconds a generation must have streamed only thinking before a message
218
220
  # for the running turn (the user's, chi send's, a parent agent's) cuts
219
- # it (Engine#cut_for_steer); 0 = never. No CLI flag: workers get no CLI args.
221
+ # it (Engine#cut_for_steer); 0 = never. hosts.<name>.steer_cut_after and
222
+ # models.<key>.steer_cut_after win. No CLI flag: workers get no CLI args.
220
223
  Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
221
224
  # A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
222
225
  # --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
@@ -242,8 +245,11 @@ module Samagotchi
242
245
  Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
243
246
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
244
247
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
245
- # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
248
+ # Seconds one execute command may run before execute.on_timeout applies (Tools::Execute::TIMEOUT_SEC).
246
249
  Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
250
+ # What happens to a command still running at that limit (Tools::Execute::ON_TIMEOUT): task hands it over as a
251
+ # background task (task_wait waits for it), kill stops it.
252
+ Entry.new(key: "execute.on_timeout", yaml_path: %w[execute on_timeout], type: :enum, default: "task", expose: %i[env config cli], enum_values: %w[task kill]),
247
253
  # Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
248
254
  Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
249
255
 
@@ -263,7 +269,7 @@ module Samagotchi
263
269
  # env+config only
264
270
  Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
265
271
  Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
266
- Entry.new(key: "skip_agent_md", yaml_path: %w[skip_agent_md], type: :bool, default: false, expose: %i[env config])
272
+ Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
267
273
  ].freeze
268
274
 
269
275
  # Top-level maps read by their own code, whose entry names and contents
@@ -274,8 +280,8 @@ module Samagotchi
274
280
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
275
281
  MAP_ENTRY_KEYS = {
276
282
  "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
277
- reasoning_history enabled remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
278
- "models" => %w[profile vision sampling thinking reasoning_history window_tokens llm_context_strategy llm_context_apply
283
+ reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
284
+ "models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
279
285
  llm_context_budget_tokens].freeze
280
286
  }.freeze
281
287
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
@@ -115,7 +115,8 @@ module Samagotchi
115
115
  # may steer or ask whether a turn runs while it loads.
116
116
  @turn_state = TurnState.new(clock: -> { monotonic_now })
117
117
  # A message for the running turn cutting its generation.
118
- @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
118
+ @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
119
+ cut_after: -> { steer_cut_after })
119
120
  @scratch = scratch
120
121
  @no_interrupt = no_interrupt
121
122
  @chat_backend = nil
@@ -266,8 +267,8 @@ module Samagotchi
266
267
  def wire_kernel(kernel, muted_memories)
267
268
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
268
269
  # the one built here does.
269
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
270
- tools: @tools)
270
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
271
+ hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
271
272
  @kernel.reminder_store = @reminder_store
272
273
  @kernel.hooks = @hooks
273
274
  @kernel.tools = @tools
@@ -1094,6 +1095,29 @@ module Samagotchi
1094
1095
  explained&.source ? explained.label : nil
1095
1096
  end
1096
1097
 
1098
+ # The seconds a cut waits for the effective model to stream only
1099
+ # thinking (SteerCutAfter.resolve), resolved on every call, as
1100
+ # turn_sampling; on any error, the global and its name.
1101
+ # @return [Array(Integer, String|nil)]
1102
+ def steer_cut_after_explained
1103
+ target = @host_registry.resolve(@effective_model_name)
1104
+ SteerCutAfter.resolve(target, names: model_lookup_names(target))
1105
+ rescue StandardError
1106
+ [SteerCut.cut_after, "steer.cut_after"]
1107
+ end
1108
+
1109
+ # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
1110
+ # reads, per host and model.
1111
+ def steer_cut_after = steer_cut_after_explained.first
1112
+
1113
+ # "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
1114
+ # effective model's cut wait and where it came from, nil for the
1115
+ # default.
1116
+ def steer_cut_summary
1117
+ seconds, source = steer_cut_after_explained
1118
+ source ? SteerCutAfter.label(seconds, source) : nil
1119
+ end
1120
+
1097
1121
  # The effective model's thinking level with where it came from and the
1098
1122
  # session's own (SettingExplained), as the next turn resolves it;
1099
1123
  # nil when it can't be resolved.
@@ -1916,6 +1940,7 @@ module Samagotchi
1916
1940
  turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1917
1941
  turn_started[:continue] = true if turn.continue
1918
1942
  turn_started[:images] = image_refs unless image_refs.empty?
1943
+ turn_started[:cut_after] = steer_cut_after
1919
1944
  emit_event(turn.on_event, turn.tag(turn_started))
1920
1945
  raise image_error if image_error
1921
1946
 
@@ -19,7 +19,7 @@ module Samagotchi
19
19
  # describes them, nil when unset; models: {downcased id => HostModel}.
20
20
  HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
21
21
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
22
- :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
22
+ :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
23
23
  :models) do
24
24
  # Every field but name may be left out: nil, models {}.
25
25
  def initialize(**fields)
@@ -50,6 +50,7 @@ module Samagotchi
50
50
  vision: ConfigFile.vision_flag(value.call(:vision), where),
51
51
  remote: ConfigFile.bool_flag(value.call(:remote), where, "remote"),
52
52
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
53
+ steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
53
54
  sampling: ConfigFile.sampling_map(value.call(:sampling), where),
54
55
  thinking: Thinking.level(value.call(:thinking), where),
55
56
  reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
@@ -170,6 +171,7 @@ module Samagotchi
170
171
  "profile" => profile, "first_token_timeout" => first_token_timeout,
171
172
  "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
172
173
  "reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
174
+ "steer_cut_after" => steer_cut_after,
173
175
  "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
174
176
  "llm_context_apply" => llm_context_apply&.to_s,
175
177
  "llm_context_budget_tokens" => llm_context_budget_tokens,
@@ -38,13 +38,14 @@ module Samagotchi
38
38
  # reasoning_history: the configured level (ReasoningHistory), nil when unset;
39
39
  # remote: the configured true/false (#remote?), nil when unset;
40
40
  # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
41
+ # steer_cut_after: the seconds a cut waits for its thinking (SteerCutAfter), nil when unset;
41
42
  # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
42
43
  # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
43
44
  # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
44
45
  # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
45
46
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
46
47
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
47
- :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
+ :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
49
  :models, keyword_init: true) do
49
50
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
50
51
  # raw-prompt loop.
@@ -142,13 +142,24 @@ module Samagotchi
142
142
  end
143
143
  end
144
144
 
145
+ # The model the config names as the default, or nil when it names none.
146
+ # It never raises.
147
+ # @return [String, nil]
148
+ def self.default_model_name
149
+ require_relative "config"
150
+ value = Samagotchi::Config.get("default.model")
151
+ return nil unless value
152
+
153
+ stripped = value.to_s.strip
154
+ stripped.empty? ? nil : stripped
155
+ end
156
+
145
157
  def self.required_model_name(model_name = nil)
146
158
  value = model_name.to_s.strip
147
159
  if value.empty?
148
- require_relative "config"
149
- value = Samagotchi::Config.get("default.model").to_s.strip
160
+ value = default_model_name
150
161
  end
151
- raise MissingModel, missing_model_message if value.empty?
162
+ raise MissingModel, missing_model_message if value.to_s.strip.empty?
152
163
 
153
164
  value
154
165
  end
@@ -10,10 +10,11 @@ module Samagotchi
10
10
  # downcased key. Each field is nil when unset: profile kept as written
11
11
  # (downcased; ModelProfile.resolve validates it), vision true/false,
12
12
  # sampling a request-field map, thinking a level, reasoning_history a
13
- # ReasoningHistory level, window_tokens, and the
13
+ # ReasoningHistory level, window_tokens, steer_cut_after whole seconds
14
+ # (0 = never, SteerCutAfter), and the
14
15
  # LLM context strategy, apply rule and budget (LLMContextStrategy). The
15
16
  # same per-model fields a hosts: entry carries (HostConfig).
16
- ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens,
17
+ ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :reasoning_history, :window_tokens, :steer_cut_after,
17
18
  :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
18
19
  # Every field may be left out (nil).
19
20
  def initialize(**fields)
@@ -37,6 +38,7 @@ module Samagotchi
37
38
  thinking: Thinking.level(value.call(:thinking), where),
38
39
  reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
39
40
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
41
+ steer_cut_after: SteerCutAfter.seconds(value.call(:steer_cut_after), where),
40
42
  llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
41
43
  llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
42
44
  llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
@@ -109,23 +109,96 @@ module Samagotchi
109
109
  # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
110
110
  # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
111
111
  # done, when kill returns).
112
+ #
113
+ # The groups the group's processes started are stopped with it: a
114
+ # descendant that moved to a group of its own (GNU `timeout N cmd`
115
+ # without --foreground calls setpgid) is out of reach of a signal to
116
+ # +pgid+. They are found (#descendant_groups) before the TERM, while the
117
+ # group's processes still run, and again before the KILL; each is
118
+ # signalled too and must be gone as well. One that already lost its
119
+ # parent (reparented to init: a setsid'd `cmd &` whose shell exited) is
120
+ # not found, and so left running.
112
121
  # @param stopped [#call] true once it has stopped (default: the leader
113
122
  # is gone)
114
123
  # @param wait [#call] (seconds) between two checks (default: a sleep)
124
+ # @param walk [Boolean] also stop the descendants' groups (one ps each
125
+ # time, ~40 ms on macOS with ~850 processes)
115
126
  # @param signal_options [Hash] for #signal (leader:, quiet:)
116
127
  # @return [Boolean] true when it stopped within the grace (no KILL sent)
117
128
  # @raise [ArgumentError] unless #signalable?
118
129
  def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
119
- kill_wait: KILL_WAIT_SEC, **signal_options)
130
+ kill_wait: KILL_WAIT_SEC, walk: true, **signal_options)
120
131
  check!(pgid)
132
+ # The group's members only: a leader reaped by now may be another process.
133
+ extra = walk ? descendant_groups(pgid) : []
121
134
  signal(pgid, "TERM", **signal_options)
122
- return true if wait_for(stopped, grace, poll, wait)
135
+ extra.each { |group| signal(group, "TERM") }
136
+ # +stopped+ is asked until it says true once (it may reap: a second
137
+ # wait on a reaped pid raises ECHILD), then only the extra groups.
138
+ done = false
139
+ all_stopped = -> { (done ||= stopped.call ? true : false) && extra.none? { |group| group_alive?(group) } }
140
+ return true if wait_for(all_stopped, grace, poll, wait)
123
141
 
142
+ extra |= descendant_groups(pgid) if walk
124
143
  signal(pgid, "KILL", **signal_options)
125
- wait_for(stopped, kill_wait, poll, wait)
144
+ extra.each { |group| signal(group, "KILL") }
145
+ wait_for(all_stopped, kill_wait, poll, wait)
126
146
  false
127
147
  end
128
148
 
149
+ # Whether any process is still in the group +pgid+.
150
+ def group_alive?(pgid)
151
+ check!(pgid)
152
+ Process.kill(0, -pgid)
153
+ true
154
+ rescue Errno::ESRCH
155
+ false
156
+ rescue Errno::EPERM
157
+ true
158
+ end
159
+
160
+ # The process groups, other than +pgid+ and chi's own, of the
161
+ # descendants of the processes in the group +pgid+ (and of the process
162
+ # +pgid+ itself with +leader+: a leader chi started that left its
163
+ # group). Read from one `ps -A -o pid=,ppid=,pgid=`; [] when ps fails.
164
+ # Only members of the group are roots, never the bare pid +pgid+ without
165
+ # +leader+: a reaped leader's pid may name some other process by now.
166
+ # @return [Array<Integer>]
167
+ def descendant_groups(pgid, leader: false)
168
+ rows = process_table
169
+ children = rows.group_by { |_pid, ppid, _group| ppid }
170
+ queue = rows.select { |_pid, _ppid, group| group == pgid }.map(&:first)
171
+ queue << pgid if leader
172
+ seen = {}
173
+ groups = []
174
+ until queue.empty?
175
+ pid = queue.shift
176
+ next if seen[pid]
177
+
178
+ seen[pid] = true
179
+ Array(children[pid]).each do |child, _ppid, group|
180
+ groups << group unless group == pgid
181
+ queue << child
182
+ end
183
+ end
184
+ own = Process.getpgrp
185
+ groups.uniq.select { |group| signalable?(group) && group != own }
186
+ end
187
+
188
+ # Every process as [pid, ppid, pgid].
189
+ # @return [Array<Array(Integer, Integer, Integer)>]
190
+ def process_table
191
+ out, status = Open3.capture2("ps", "-A", "-o", "pid=,ppid=,pgid=", err: File::NULL)
192
+ return [] unless status.success?
193
+
194
+ out.lines.filter_map do |line|
195
+ fields = line.split.map { |field| Integer(field, exception: false) }
196
+ fields if fields.size == 3 && fields.all?
197
+ end
198
+ rescue SystemCallError, IOError
199
+ []
200
+ end
201
+
129
202
  # Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
130
203
  # @return [Boolean] whether it stopped
131
204
  def wait_for(stopped, seconds, poll, wait)