samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "question_slot"
4
+ require_relative "../turn_flow"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -12,7 +13,10 @@ module Samagotchi
12
13
  # Bridge). An approval (kind "approval") takes only a number, an exact
13
14
  # label, y (the first option, Allow once) or n (the last, Deny), plus
14
15
  # an optional "; reason"; a substring would pick the wrong one ("y" is
15
- # in "Deny").
16
+ # in "Deny"). The step-limit question (kind "continue") reads the
17
+ # continue words (TurnFlow.continue_decision): Enter, yes, y or
18
+ # /continue continue; no stops; "no, why" stops with the reason; 1 and
19
+ # 2 pick the options.
16
20
  class QuestionPrompt
17
21
  DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
18
22
 
@@ -51,11 +55,13 @@ module Samagotchi
51
55
  @multi = !!field.(:multi_select)
52
56
  @free = !!field.(:allow_freeform)
53
57
  @approval = field.(:kind).to_s == "approval"
58
+ @continue = field.(:kind).to_s == "continue"
54
59
  end
55
60
 
56
61
  def multi? = @multi
57
62
  def free? = @free
58
63
  def approval? = @approval
64
+ def continue? = @continue
59
65
 
60
66
  # The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
61
67
  # short id), or nil when cleared.
@@ -77,6 +83,7 @@ module Samagotchi
77
83
  # What a question closed with no answer here says: a relayed approval
78
84
  # names where it went; else "(question cancelled)".
79
85
  def closed_text(reason)
86
+ return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
80
87
  return "(question cancelled)" unless @delegate
81
88
 
82
89
  case reason.to_s
@@ -98,6 +105,10 @@ module Samagotchi
98
105
  hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
99
106
  end
100
107
 
108
+ # Why a step-limit question closed unanswered here.
109
+ CONTINUE_CLOSED = { "dropped" => "(dropped: a new prompt came)", "answered" => "(answered with /continue)",
110
+ "superseded" => "(set aside for another question)", "replaced" => "(asked again)" }.freeze
111
+
101
112
  # The most diff lines #preview_lines prints; the web shows them all.
102
113
  PREVIEW_LINES = 40
103
114
 
@@ -138,7 +149,7 @@ module Samagotchi
138
149
  # @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
139
150
  def answer_text(answer)
140
151
  picked = answer.selected.to_a
141
- if approval?
152
+ if approval? || continue?
142
153
  choice = picked.first || options.last
143
154
  return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
144
155
  end
@@ -150,6 +161,7 @@ module Samagotchi
150
161
  # @return [Answer]
151
162
  def parse(raw)
152
163
  return parse_approval(raw) if approval?
164
+ return parse_continue(raw) if continue?
153
165
 
154
166
  raw = raw.to_s.strip
155
167
  # "1,3; my text": the first ';' separates the selection from freeform text.
@@ -178,6 +190,9 @@ module Samagotchi
178
190
  def mark = approval? ? "! " : "? "
179
191
 
180
192
  def slot_hint
193
+ if continue?
194
+ return "Enter or yes = #{options.first}; no = #{options.last}; no, <reason> = #{options.last} and tell the model why"
195
+ end
181
196
  if approval?
182
197
  return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
183
198
  "Enter alone denies"
@@ -189,6 +204,23 @@ module Samagotchi
189
204
  hint.join("; ")
190
205
  end
191
206
 
207
+ # The continue words, or an option's number. Empty is Continue (Enter
208
+ # alone, as at the REPL's continue prompt).
209
+ def parse_continue(raw)
210
+ text = raw.to_s.strip
211
+ if text.match?(/\A\d+\z/)
212
+ idx = text.to_i - 1
213
+ return Answer.new(selected: [options[idx]]) if idx.between?(0, options.size - 1)
214
+ end
215
+ decision, reason = TurnFlow.continue_decision(text)
216
+ case decision
217
+ when :resume then Answer.new(selected: [options.first])
218
+ when :abort then Answer.new(selected: [options.last])
219
+ when :abort_with_reason then Answer.new(selected: [options.last], freeform: reason)
220
+ else Answer.new(error: "Answer yes (Enter alone too), no, or no, <reason>.")
221
+ end
222
+ end
223
+
192
224
  def parse_approval(raw)
193
225
  sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
194
226
  free_part = nil if free_part.to_s.empty?
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "io/console"
6
6
  require "reline"
7
7
 
8
+ require_relative "iteration_limit"
8
9
  require_relative "model_profile"
9
10
  require_relative "turn_note"
10
11
  require_relative "config"
@@ -29,6 +30,7 @@ require_relative "terminal_ui/input_support"
29
30
  require_relative "terminal_ui/image_input"
30
31
  require_relative "terminal_ui/plain_surface"
31
32
  require_relative "terminal_ui/live_region"
33
+ require_relative "terminal_ui/one_shot_sink"
32
34
  require_relative "terminal_ui/question_prompt"
33
35
  require_relative "terminal_ui/repl_input"
34
36
  require_relative "terminal_ui/status_row"
@@ -209,7 +211,9 @@ module Samagotchi
209
211
  # follow-up REPL (--non-interactive) or drops into the REPL carrying the
210
212
  # post-turn conversation.
211
213
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
212
- # turn failed (bin/chi exits 1)
214
+ # turn failed, :empty_answer when a -p --non-interactive turn ended
215
+ # with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
216
+ # cancelled that turn (bin/chi exits 130)
213
217
  def run
214
218
  # --non-interactive with no --prompt is a harmless no-op exit: build
215
219
  # nothing and return (no transient session, no banner).
@@ -239,25 +243,36 @@ module Samagotchi
239
243
  if @prompt && @non_interactive
240
244
  # Headless / CI mode: run directly without TTY rendering.
241
245
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
242
- result = @engine.run_turn(
243
- session,
244
- @prompt,
245
- on_event: nil,
246
- max_iterations: 1000,
247
- cancel_controller: nil,
248
- images: ImageInput.extract(@prompt)
249
- )
246
+ # Retry lines and hook notices on stderr; stdout gets the answer below.
247
+ sink = OneShotSink.new
248
+ begin
249
+ result = @engine.run_turn(
250
+ session,
251
+ @prompt,
252
+ on_event: sink,
253
+ max_iterations: IterationLimit.for(no_interrupt: true),
254
+ cancel_controller: nil,
255
+ images: ImageInput.extract(@prompt)
256
+ )
257
+ rescue LLM::ProviderError, ImageStore::Error => e
258
+ return one_shot_failed(session, e)
259
+ rescue Interrupt
260
+ return one_shot_interrupted(session)
261
+ end
250
262
  # No answer (the empty-answer retries used up): no text, or the chat
251
263
  # loop's placeholder (LLM::ModelResult#empty_answer?).
252
264
  if result.empty_answer?
253
- # Nothing to print: say so on stderr and fail, so a script that
254
- # pipes the answer doesn't take the silence for one.
265
+ # Nothing to print: say so on stderr and fail (bin/chi exits 1),
266
+ # so a script that pipes the answer doesn't take the silence for one.
255
267
  session.save
256
268
  warn EMPTY_ANSWER_ERROR
257
- exit 1
269
+ sink.flush
270
+ return :empty_answer
258
271
  end
259
272
  @surface.commit(result.output)
260
273
  session.save
274
+ # The after_turn hooks' notices, after the answer as the REPL shows them.
275
+ sink.flush
261
276
  return
262
277
  end
263
278
 
@@ -285,6 +300,18 @@ module Samagotchi
285
300
 
286
301
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
287
302
 
303
+ # A -p --non-interactive turn that failed (bin/chi exits 1): the error,
304
+ # then how to go on. The Engine saved the failed turn with its prompt,
305
+ # so the session is kept and `chi --resume` takes it up again.
306
+ # @return [Symbol] :turn_failed
307
+ def one_shot_failed(session, error)
308
+ warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
309
+ if !@scratch && Session.exist?(session.id)
310
+ warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
311
+ end
312
+ :turn_failed
313
+ end
314
+
288
315
  # `chi -p "/models" --non-interactive`: the command's output; a refused
289
316
  # one fails the run (bin/chi exits 1). The session is saved only when
290
317
  # the command changed it.
@@ -301,6 +328,19 @@ module Samagotchi
301
328
  :turn_failed if result.status == :error
302
329
  end
303
330
 
331
+ # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
332
+ # a cancel note in the session (it saves only a failed turn), so save
333
+ # it here: `chi --resume` takes it up again. A scratch session goes in
334
+ # #run's ensure, so there is nothing to keep.
335
+ # @return [Symbol] :interrupted
336
+ def one_shot_interrupted(session)
337
+ return :interrupted if @scratch
338
+
339
+ session.save
340
+ warn "chi: canceled (Ctrl-C); the session is kept: continue it with chi --resume #{session.id}"
341
+ :interrupted
342
+ end
343
+
304
344
  # Delete the scratch session, the last thing the REPL does. Quiet after
305
345
  # a one-shot or on the way out of an error or a signal.
306
346
  def delete_scratch(session, quiet:)
@@ -399,19 +439,29 @@ module Samagotchi
399
439
  system_message = { role: "system", content: seed_system_prompt }
400
440
  if @resume_session
401
441
  messages = ContextNote.with_system_head(session.messages.dup, system_message)
402
- @surface.commit("Resumed session: #{session.id}")
442
+ banner("Resumed session: #{session.id}")
403
443
  saved = @engine.saved_recap
404
- @surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
444
+ banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
405
445
  elsif @scratch
406
446
  messages = [system_message]
407
- @surface.commit("Scratch session: nothing is kept, it is deleted when you leave.")
447
+ banner("Scratch session: nothing is kept, it is deleted when you leave.")
408
448
  else
409
449
  messages = [system_message]
410
- @surface.commit("Session: #{session.id}")
450
+ banner("Session: #{session.id}")
411
451
  end
412
452
  messages
413
453
  end
414
454
 
455
+ # A line about the session, not the answer: on stderr in a
456
+ # --non-interactive run, whose stdout is the answer's alone (a script
457
+ # or a parent agent reads it), and before the turn, so it comes first.
458
+ def banner(text)
459
+ return @surface.commit(text) unless @non_interactive
460
+
461
+ $stderr.puts(text)
462
+ $stderr.flush
463
+ end
464
+
415
465
  # Interactive REPL loop. Session seed + messages are built by #run and
416
466
  # threaded in here (so --prompt / --resume share one code path). The
417
467
  # session's conversation is the single working copy: turns go through
@@ -485,7 +535,7 @@ module Samagotchi
485
535
  # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
486
536
  # Engine#run_turn, rendering via @renderer.
487
537
  # @param images [Array<Hash>] the prompt's `@path` images ({path:})
488
- def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
538
+ def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
489
539
  cancellation_controller = CancellationController.new
490
540
  @active_cancel_controller = cancellation_controller
491
541
  @renderer.begin_turn
@@ -655,6 +705,9 @@ module Samagotchi
655
705
  return
656
706
  end
657
707
  return if show_local_command(input)
708
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
709
+ hint = command_registry.unknown_command_hint(input)
710
+ return @surface.commit(hint) if hint
658
711
 
659
712
  @turn_flow.before_prompt_turn
660
713
  persist_recent_history(input)
@@ -941,6 +994,11 @@ module Samagotchi
941
994
  # A command, never steering text (/archive waits, back in the prompt).
942
995
  return command_during_turn(line) if local || command_registry.command?(line)
943
996
  return true if line.strip.empty?
997
+ # A typo of a command (/modle) is not steering text: say so, take it.
998
+ if (hint = command_registry.unknown_command_hint(line))
999
+ @surface.commit(hint)
1000
+ return true
1001
+ end
944
1002
  # Steering merges text only: a line with images runs as the next turn.
945
1003
  return image_line_waits if ImageInput.extract(line).any?
946
1004
 
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  TOOL_SCHEMAS = [
19
19
  {
20
20
  name: "execute",
21
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata.",
21
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. Don't background with &; use task_create.",
22
22
  parameters: {
23
23
  type: "object",
24
24
  properties: {
@@ -46,11 +46,11 @@ module Samagotchi
46
46
  },
47
47
  start_line: {
48
48
  type: "integer",
49
- description: "Optional start line (1-based, inclusive). Must be provided with end_line."
49
+ description: "Optional start line (1-based, inclusive). Without end_line, reads to the end of the file."
50
50
  },
51
51
  end_line: {
52
52
  type: "integer",
53
- description: "Optional end line (1-based, inclusive). Must be provided with start_line."
53
+ description: "Optional end line (1-based, inclusive). Without start_line, reads from line 1."
54
54
  }
55
55
  },
56
56
  required: ["path"]
@@ -153,7 +153,7 @@ module Samagotchi
153
153
  },
154
154
  {
155
155
  name: "task_create",
156
- description: "Start a background task for a long-running shell command. Returns task id and output path for later inspection.",
156
+ description: "Start a background task for a long-running shell command. Servers and anything meant to keep running belong here, not in execute with &. Returns task id and output path for later inspection.",
157
157
  parameters: {
158
158
  type: "object",
159
159
  properties: {
@@ -139,7 +139,7 @@ module Samagotchi
139
139
  when :canceled
140
140
  canceled_result(child_id)
141
141
  when :no_reply
142
- result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
142
+ result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
143
143
  when :worker_gone
144
144
  result(child_id, status,
145
145
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
@@ -168,8 +168,8 @@ module Samagotchi
168
168
  end
169
169
 
170
170
  # ParentReport's line for a turn that left no reply, about the child.
171
- def no_reply_text(wait)
172
- ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
171
+ def no_reply_text(wait, child_id)
172
+ ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
173
173
  end
174
174
 
175
175
  def result(child_id, status, text)
@@ -184,9 +184,18 @@ module Samagotchi
184
184
 
185
185
  # The whole question, as `chi send --wait` prints it (ParentReport):
186
186
  # its text, options and the commands that answer it.
187
+ # A step-limit question (kind continue) is the parent model's to
188
+ # decide (it isn't relayed: Continue grants no permission): continue
189
+ # it with chi answer through execute, send a follow-up instead, or
190
+ # report back.
187
191
  def waiting_text(child_id, pending)
188
- "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}" \
189
- "delegate_result #{child_id} waits again once it is answered."
192
+ text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
193
+ if ParentReport.continue?(pending)
194
+ text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
195
+ "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
196
+ "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
197
+ end
198
+ "#{text}delegate_result #{child_id} waits again once it is answered."
190
199
  end
191
200
 
192
201
  def cut(text)
@@ -62,6 +62,9 @@ module Samagotchi
62
62
  "Error: #{e.message}"
63
63
  end
64
64
 
65
+ START_LINE_REQUIRED = "Error: end_line alone isn't enough for a range edit; " \
66
+ "pass start_line too (1-based, the first line to replace)"
67
+
65
68
  def self.apply_range_mode(new_text, path:, start_line:, end_line:, read:)
66
69
  return "Error: file not found: #{path}" unless File.exist?(path)
67
70
  return NEW_TEXT_REQUIRED if new_text.nil?
@@ -70,7 +73,9 @@ module Samagotchi
70
73
 
71
74
  start_num = parse_positive_line_number(start_line, "start_line")
72
75
  return start_num if start_num.is_a?(String)
73
- return "Error: start_line must be provided for range edits" if start_num.nil?
76
+ # Not defaulted to 1 like read: a range edit replaces the span, so a
77
+ # guessed start would silently overwrite the top of the file.
78
+ return START_LINE_REQUIRED if start_num.nil?
74
79
 
75
80
  source = read.(path)
76
81
  lines = source.lines
@@ -15,11 +15,21 @@ module Samagotchi
15
15
  # <tool name="execute">ruby path/to/script.rb</tool>
16
16
  class Execute
17
17
  NAME = "execute"
18
- TIMEOUT_SEC = 30
18
+ TIMEOUT_SEC = 120
19
19
  STOP_GRACE_SEC = 1.0
20
20
  STOP_POLL_INTERVAL_SEC = 0.05
21
21
  WAIT_SLICE_SEC = 0.2
22
22
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
23
+ TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
24
+ # How long the output may stay open once the shell has exited: a
25
+ # `server &` child keeps the pipes, and the read would never end.
26
+ BACKGROUND_GRACE_SEC = 1.5
27
+ BACKGROUND_HINT = "(a background process kept the output open and was stopped; " \
28
+ "to keep a server or long job running, start it with task_create)"
29
+ # It had left the process group (setsid), so it is still running.
30
+ BACKGROUND_DETACHED_HINT = "(a background process kept the output open and was left running; " \
31
+ "to keep a server or long job running, start it with task_create)"
32
+ READ_CHUNK_BYTES = 65_536
23
33
 
24
34
  def self.name = NAME
25
35
 
@@ -34,7 +44,7 @@ module Samagotchi
34
44
  return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
35
45
 
36
46
  timeout_sec = timeout_seconds
37
- stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
47
+ stdout, stderr, status, held_open = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
38
48
  env: env)
39
49
 
40
50
  stdout_block = output_block("stdout", stdout)
@@ -49,11 +59,12 @@ module Samagotchi
49
59
  # model as "done, something happened".
50
60
  silent = stdout_block.nil? && stderr_block.nil?
51
61
  parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
62
+ parts << (held_open == :detached ? BACKGROUND_DETACHED_HINT : BACKGROUND_HINT) if held_open
52
63
  parts.join("\n")
53
64
  rescue CommandTimedOut => e
54
- # Keep the "Error:" first line (callers classify on it) and return
55
- # whatever the command printed before it was killed.
56
- stopped_result("Error: command timed out after #{timeout_sec}s", e)
65
+ # Keep the "Error:" first line (callers classify on it), say how to run
66
+ # a long command, and return whatever it printed before it was killed.
67
+ stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
57
68
  rescue CommandCancelled => e
58
69
  stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
59
70
  rescue => e
@@ -74,13 +85,14 @@ module Samagotchi
74
85
  status = nil
75
86
  timed_out = false
76
87
  was_cancelled = false
88
+ aborted = false
89
+ held_open = false
77
90
  started = monotonic_time
78
91
  deadline = started + timeout_sec
79
92
 
80
93
  Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
81
94
  stdin.close
82
- stdout_reader = reader_thread_for(stdout)
83
- stderr_reader = reader_thread_for(stderr)
95
+ readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
84
96
 
85
97
  begin
86
98
  until wait_thr.join(WAIT_SLICE_SEC)
@@ -99,36 +111,103 @@ module Samagotchi
99
111
  # An Interrupt (or anything else) mid-wait: the child is in its own
100
112
  # process group, so it never saw the SIGINT. Kill it, or the reads
101
113
  # below block until it closes stdout.
114
+ aborted = true
102
115
  terminate_process_tree(wait_thr.pid)
103
116
  raise
104
117
  ensure
105
- # Read the buffered output before closing the pipes. For a process
106
- # that exits almost instantly (e.g. `echo hello`), the background
107
- # reader thread may not have scheduled its `io.read` yet; joining
108
- # the reader after the pipe is closed would return "".
109
- stdout_text = stdout_reader.value
110
- stderr_text = stderr_reader.value
111
- close_quietly(stdout)
112
- close_quietly(stderr)
118
+ # The shell is gone, but a child it put in the background (`cmd &`)
119
+ # may still hold the pipes: wait for EOF a short grace only, then
120
+ # stop the command's process group and keep the output so far.
121
+ # Already stopped (timeout, Stop, an exception): no more checks.
122
+ stopping = timed_out || was_cancelled || aborted
123
+ outcome = await_readers(readers, until_time: monotonic_time + BACKGROUND_GRACE_SEC,
124
+ deadline: stopping ? nil : deadline,
125
+ cancelled: stopping ? -> { false } : cancelled)
126
+ unless outcome == :done
127
+ was_cancelled = true if outcome == :cancelled
128
+ timed_out = true if outcome == :timeout
129
+ stopped = stop_process_group(wait_thr.pid, readers)
130
+ held_open = (stopped ? :stopped : :detached) if outcome == :grace && !stopping
131
+ end
132
+ readers.each(&:finish)
133
+ stdout_text, stderr_text = readers.map(&:text)
113
134
  end
114
135
  end
115
136
 
116
137
  raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
117
138
  raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
118
139
 
119
- [stdout_text, stderr_text, status]
140
+ [stdout_text, stderr_text, status, held_open]
120
141
  end
121
142
  private_class_method :run_command
122
143
 
123
- def self.reader_thread_for(io)
124
- Thread.new do
125
- Thread.current.report_on_exception = false
126
- io.read.to_s
127
- rescue IOError, EOFError
128
- ""
144
+ # Waits for both readers to hit EOF until +until_time+, the overall
145
+ # +deadline+ or a Stop. Returns :done, :grace, :timeout or :cancelled.
146
+ def self.await_readers(readers, until_time:, deadline:, cancelled:)
147
+ loop do
148
+ return :done if readers.all?(&:done?)
149
+ return :cancelled if cancelled.call
150
+ return :timeout if deadline && monotonic_time > deadline
151
+ return :grace if monotonic_time > until_time
152
+
153
+ readers.find { |r| !r.done? }&.wait(STOP_POLL_INTERVAL_SEC)
154
+ end
155
+ end
156
+ private_class_method :await_readers
157
+
158
+ # The shell has exited (its pid is reaped) but its process group still
159
+ # has members holding the output: TERM the group, then KILL it. A process
160
+ # that left the group (setsid) is out of reach; the pipes get closed.
161
+ # True once the output was released, false if something still holds it.
162
+ def self.stop_process_group(pgid, readers)
163
+ signal_group(pgid, "TERM")
164
+ return true if await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil,
165
+ cancelled: -> { false }) == :done
166
+
167
+ signal_group(pgid, "KILL")
168
+ await_readers(readers, until_time: monotonic_time + STOP_GRACE_SEC, deadline: nil, cancelled: -> { false }) == :done
169
+ end
170
+ private_class_method :stop_process_group
171
+
172
+ # Only the group: the leader's pid is reaped, so it may name another process.
173
+ def self.signal_group(pgid, signal)
174
+ Process.kill(signal, -pgid)
175
+ rescue Errno::ESRCH, Errno::EPERM
176
+ nil
177
+ end
178
+ private_class_method :signal_group
179
+
180
+ # Reads a pipe in chunks on its own thread, so the output read so far
181
+ # survives the pipe being closed under it.
182
+ class OutputReader
183
+ def initialize(io)
184
+ @io = io
185
+ @buffer = String.new(encoding: Encoding::BINARY)
186
+ @thread = Thread.new do
187
+ Thread.current.report_on_exception = false
188
+ loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
189
+ rescue EOFError, IOError
190
+ nil
191
+ end
192
+ end
193
+
194
+ def done? = !@thread.alive?
195
+
196
+ def wait(seconds) = @thread.join(seconds)
197
+
198
+ # Closes the pipe (a reader still blocked gets IOError) and ends the
199
+ # thread; the text is safe to read afterwards.
200
+ def finish
201
+ @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
202
+ Execute.send(:close_quietly, @io)
203
+ @thread.kill unless @thread.join(STOP_GRACE_SEC)
204
+ end
205
+
206
+ def text
207
+ @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
129
208
  end
130
209
  end
131
- private_class_method :reader_thread_for
210
+ private_constant :OutputReader
132
211
 
133
212
  def self.close_quietly(io)
134
213
  io.close unless io.closed?
@@ -124,7 +124,8 @@ module Samagotchi
124
124
 
125
125
  start_num = parse_positive_line_number(start_line, "start_line")
126
126
  return start_num if start_num.is_a?(String)
127
- return "Error: start_line must be provided for range reads" if start_num.nil?
127
+ # end_line alone reads from the top of the file.
128
+ start_num ||= 1
128
129
 
129
130
  end_provided = !blank?(end_line)
130
131
  end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
@@ -108,12 +108,13 @@ module Samagotchi
108
108
  @checkpoint = nil
109
109
  end
110
110
 
111
- # Answer the offer with no: the interrupted turn is discarded, and a
112
- # reason (with a summary of that turn) is left for the model to read.
111
+ # Answer the offer with no (Stop): the interrupted turn stays, as
112
+ # after a Ctrl-C or a new prompt (!rollback still erases it), and the
113
+ # model reads that it wasn't continued: a note, or the user's reason
114
+ # (with a summary of that turn).
113
115
  def abort_continue!(reason: nil)
114
- restore(@checkpoint) if @checkpoint
115
- @checkpoint = nil
116
- @engine.append_messages([{ role: "user", content: reason_message(reason) }]) if reason
116
+ message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
117
+ @engine.append_messages([message])
117
118
  @offer = nil
118
119
  end
119
120
 
@@ -212,9 +213,9 @@ module Samagotchi
212
213
 
213
214
  def reason_message(reason)
214
215
  context = @offer && @offer[:context]
215
- lines = ["I chose not to continue the interrupted turn because: #{reason}"]
216
+ lines = ["I chose not to continue the turn that ran out of steps because: #{reason}"]
216
217
  lines << ""
217
- lines << "Interrupted turn summary:"
218
+ lines << "Its work so far stays. Interrupted turn summary:"
218
219
  original_prompt = context && context[:original_prompt]
219
220
  lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
220
221
 
@@ -131,6 +131,13 @@ module Samagotchi
131
131
  (entry[:kind] || entry["kind"]).to_s == KIND
132
132
  end
133
133
 
134
+ # The step-limit offer answered Stop (no reason): the partial turn
135
+ # stays, and the model reads that it wasn't continued.
136
+ def not_continued
137
+ message("the previous turn ran out of steps before it answered, and the user chose not to continue it. " \
138
+ "Its work so far (tool calls, file changes) stays; wait for the user's next message.")
139
+ end
140
+
134
141
  def message(text)
135
142
  { role: "system", content: "#{OPEN}#{text}#{CLOSE}", kind: KIND }
136
143
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.17.0"
4
+ VERSION = "0.18.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end