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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- 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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
banner("Resumed session: #{session.id}")
|
|
403
443
|
saved = @engine.saved_recap
|
|
404
|
-
|
|
444
|
+
banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
|
|
405
445
|
elsif @scratch
|
|
406
446
|
messages = [system_message]
|
|
407
|
-
|
|
447
|
+
banner("Scratch session: nothing is kept, it is deleted when you leave.")
|
|
408
448
|
else
|
|
409
449
|
messages = [system_message]
|
|
410
|
-
|
|
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:
|
|
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).
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
55
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -108,12 +108,13 @@ module Samagotchi
|
|
|
108
108
|
@checkpoint = nil
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Answer the offer with no: the interrupted turn
|
|
112
|
-
#
|
|
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
|
-
|
|
115
|
-
@
|
|
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
|
|
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
|
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -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
|