samagotchi 0.18.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 +35 -1
- data/bin/chi +5 -2
- data/docs/cli.md +7 -1
- data/docs/guardrails.md +8 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/engine.rb +26 -8
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -3
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui.rb +65 -16
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/execute.rb +97 -19
- data/lib/samagotchi/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34f4c3e1960aa23902e27160bd1f325ba035731c283a93b2cd819676481c90a2
|
|
4
|
+
data.tar.gz: 708e58e5bfc26d10fe9f20bd1b696f6355e2a25749a36ca6503e82ca9077fa64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56c634e50c34f3fd027ca9ed7208d7edaab21a639050fe321c1d9fa5e8a0e818a31ae6c6351f8c21c0417984f3235a1a44b14aaa3d415b392a85d3bff2916aa1
|
|
7
|
+
data.tar.gz: 41862c98747027f791e4aa59b4c6d2eda092d818e3492212fbc79a24970c74734c3196c480511876aa97b95a8e1245c8bfd6fceccb73d47ade67d3f96ced89fa
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,39 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.18.1] - 2026-10-03
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The `execute` tool no longer hangs when a command leaves a process in the background (`server &`, or
|
|
16
|
+
`cd DIR && nohup server > log 2>&1 &`) that keeps its output open: after a short grace it stops that process and
|
|
17
|
+
returns the output, with a note to start servers and long jobs with `task_create`. Stop and
|
|
18
|
+
`execute.timeout_sec` work during the wait too.
|
|
19
|
+
- A running session no longer denies every tool call after an installed bundle's rules are upgraded to a version
|
|
20
|
+
that needs a newer chi (`requires_chi`): it keeps the rules it loaded before and says once to restart the session
|
|
21
|
+
(`chi sessions stop ID`, then `chi --resume ID`).
|
|
22
|
+
- `chi -p … --non-interactive` keeps stdout for the answer alone: the `Session:` (or `Resumed session:`) line goes
|
|
23
|
+
to stderr, before the turn. A failed turn now says after its error that the session is kept with the prompt and
|
|
24
|
+
how to continue it (`chi --resume ID`).
|
|
25
|
+
- Ctrl-C in `chi -p … --non-interactive` keeps the session: the prompt and a cancel note are saved, so
|
|
26
|
+
`chi --resume ID` has them, and stderr says so instead of a Ruby backtrace. The exit status stays 130.
|
|
27
|
+
- `chi -p` shows the after_turn hooks' notices (source-links' `sources:`, any notifier), which it lost: with
|
|
28
|
+
`--non-interactive` on stderr after the answer; attached (input from a pipe) it waits for them and shows them
|
|
29
|
+
before it detaches.
|
|
30
|
+
- `chi -p … --non-interactive` prints the retry lines on stderr (`↻ empty answer, asking again (1/1)`, `retrying
|
|
31
|
+
(1/5 in 0.5s): …`), as the REPL shows them, instead of nothing.
|
|
32
|
+
- A native llama.cpp turn sends `GET /props` once, not twice.
|
|
33
|
+
- After a failed `/props` probe, a server that then answers is probed again at the next turn, instead of the
|
|
34
|
+
failure being reused for 30 s.
|
|
35
|
+
- A request to a model server is sent only as many times as chi's own retries say: Ruby's hidden extra retry is off.
|
|
36
|
+
- The served-model notice no longer treats `gpt-4` and `gpt-4o` as the same model: a served name counts as the
|
|
37
|
+
asked one only when what follows starts with `:`, `-`, `@` or `/` (`qwen3` = `qwen3:latest`).
|
|
38
|
+
- A Gemma thought channel (`<|channel>thought` … `<channel|>`) is stripped as a whole block, so the model's
|
|
39
|
+
reasoning no longer shows up in the answer.
|
|
40
|
+
- An unknown model profile name falls back to `qwen36`, the same default as everywhere else, instead of `gemma4`.
|
|
41
|
+
|
|
42
|
+
Update with `gem update samagotchi` (or `chi update`); no bundle changed.
|
|
43
|
+
|
|
11
44
|
## [0.18.0] - 2026-10-03
|
|
12
45
|
|
|
13
46
|
### Added
|
|
@@ -1002,7 +1035,8 @@ and long-lived sessions.
|
|
|
1002
1035
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1003
1036
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1004
1037
|
|
|
1005
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.18.
|
|
1038
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.18.1...HEAD
|
|
1039
|
+
[0.18.1]: https://github.com/dm1try/samagotchi/compare/v0.18.0...v0.18.1
|
|
1006
1040
|
[0.18.0]: https://github.com/dm1try/samagotchi/compare/v0.17.0...v0.18.0
|
|
1007
1041
|
[0.17.0]: https://github.com/dm1try/samagotchi/compare/v0.16.0...v0.17.0
|
|
1008
1042
|
[0.16.0]: https://github.com/dm1try/samagotchi/compare/v0.15.0...v0.16.0
|
data/bin/chi
CHANGED
|
@@ -314,6 +314,7 @@ if launch == :attached
|
|
|
314
314
|
exit Samagotchi::TerminalUI::AttachLauncher.exit_status(ended)
|
|
315
315
|
end
|
|
316
316
|
|
|
317
|
+
require "samagotchi/cli/exit"
|
|
317
318
|
begin
|
|
318
319
|
ended = Samagotchi::TerminalUI.new(
|
|
319
320
|
prompt: options[:prompt],
|
|
@@ -326,8 +327,10 @@ begin
|
|
|
326
327
|
model_name: options[:model],
|
|
327
328
|
scratch: scratch
|
|
328
329
|
).run
|
|
329
|
-
# `chi -p X </dev/null`: the turn failed (nothing was sent again)
|
|
330
|
-
|
|
330
|
+
# `chi -p X </dev/null`: the turn failed (nothing was sent again); -p
|
|
331
|
+
# --non-interactive: the turn ended with no answer, or Ctrl-C cancelled it.
|
|
332
|
+
exit 1 if %i[turn_failed empty_answer].include?(ended)
|
|
333
|
+
exit Samagotchi::CLI::Exit::INTERRUPTED if ended == :interrupted
|
|
331
334
|
rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
|
|
332
335
|
warn "Error: #{e.message}"
|
|
333
336
|
exit 1
|
data/docs/cli.md
CHANGED
|
@@ -224,7 +224,13 @@ Notes:
|
|
|
224
224
|
messages as turn context (a `-p` run on a resumed session never clobbers its
|
|
225
225
|
conversation).
|
|
226
226
|
- Non-interactive runs (`-p` with `--non-interactive`, or bare `--non-interactive`)
|
|
227
|
-
print only the final result output — no spinner, status line, or REPL.
|
|
227
|
+
print only the final result output on stdout — no spinner, status line, or REPL.
|
|
228
|
+
Everything else goes to stderr: the `Session: ID` line first, before the turn;
|
|
229
|
+
retry lines and hooks' notices as they come, the after_turn hooks' ones
|
|
230
|
+
(source-links' `sources:`) after the answer; and the error of a failed turn,
|
|
231
|
+
then `chi: the session is kept with your prompt; continue it with: chi
|
|
232
|
+
--resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
|
|
233
|
+
cancel note and exits 130.
|
|
228
234
|
|
|
229
235
|
### Scratch sessions
|
|
230
236
|
|
data/docs/guardrails.md
CHANGED
|
@@ -295,6 +295,14 @@ restart. A rule that doesn't parse (an unknown key, a bad regex, no
|
|
|
295
295
|
verdict, a `disable:` that isn't an id or a list of ids) makes chi **deny every tool call** and say why, rather than run
|
|
296
296
|
without it.
|
|
297
297
|
|
|
298
|
+
A bundle whose `requires_chi` the running chi doesn't meet (installed by a
|
|
299
|
+
newer chi while the session's worker kept running) isn't read again: the
|
|
300
|
+
rules the session loaded from it before stay, and a `guardrails>` line says
|
|
301
|
+
once per bundle version to restart the session (`chi sessions stop ID`, then
|
|
302
|
+
`chi --resume ID`). A chi that never loaded that bundle's rules (it starts
|
|
303
|
+
older than the bundle) denies every tool call, as for a rule that doesn't
|
|
304
|
+
parse, until chi is updated or the bundle uninstalled.
|
|
305
|
+
|
|
298
306
|
## The guardrails bundle
|
|
299
307
|
|
|
300
308
|
```sh
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -31,12 +31,39 @@ module Samagotchi
|
|
|
31
31
|
CONTEXT_WINDOW_PROBE_READ_TIMEOUT = 2
|
|
32
32
|
# Seconds a probe that got no answer (refused, timed out) is remembered:
|
|
33
33
|
# a down or hung server then costs one probe per window, not one per
|
|
34
|
-
# generation, and a new turn doesn't ask again at once.
|
|
34
|
+
# generation, and a new turn doesn't ask again at once. A request to the
|
|
35
|
+
# host that succeeds drops the remembered failure (see
|
|
36
|
+
# #clear_props_failures!).
|
|
35
37
|
PROPS_FAILURE_TTL = 30
|
|
36
38
|
|
|
37
39
|
DEFAULT_TRANSPORT = :llama_cpp
|
|
38
40
|
VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
|
|
39
41
|
|
|
42
|
+
# One /props cache per host per process. A host has more than one Client
|
|
43
|
+
# in a run (the registry's, a rebuilt HostRegistry's, the Engines of a
|
|
44
|
+
# TUI and a spec): with a cache each, every one of them asks /props for
|
|
45
|
+
# the same server. Keyed by the host's base URL (scheme://host:port), so
|
|
46
|
+
# two Clients of the same server share the answers their turns read.
|
|
47
|
+
# Entries live for the process: they are the same small hash per host
|
|
48
|
+
# (answers by model, failures by model with their timestamps).
|
|
49
|
+
@props_store = {}
|
|
50
|
+
@props_store_mutex = Mutex.new
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# The shared props entry for a host's base URL:
|
|
54
|
+
# { answers: { model => ServerProps }, failures: { model => [props, at] },
|
|
55
|
+
# mutex: Mutex }. The caller may hold the entry's own mutex.
|
|
56
|
+
def props_entry(base_url)
|
|
57
|
+
@props_store_mutex.synchronize { @props_store[base_url] ||= { answers: {}, failures: {}, mutex: Mutex.new } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Forget every host's answers and failures. Specs call it per example
|
|
61
|
+
# (the store outlives one); nothing in a run does.
|
|
62
|
+
def reset_props_store!
|
|
63
|
+
@props_store_mutex.synchronize { @props_store.clear }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
# Wire-format strategy for one server transport. `Client` keeps the
|
|
41
68
|
# transport-agnostic request/retry/stream loop; everything that differs
|
|
42
69
|
# between llama.cpp's native API and the OpenAI-compatible servers
|
|
@@ -175,9 +202,13 @@ module Samagotchi
|
|
|
175
202
|
@open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
|
|
176
203
|
@read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
|
|
177
204
|
@transport = build_transport(resolve_transport(transport))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
# This host's process-wide /props entry (see self.props_entry): its
|
|
206
|
+
# answers and failures are shared with every other Client of the same
|
|
207
|
+
# server.
|
|
208
|
+
@props_entry = self.class.props_entry("#{@scheme}://#{@host}:#{@port}")
|
|
209
|
+
@props_cache = @props_entry[:answers]
|
|
210
|
+
@props_failures = @props_entry[:failures]
|
|
211
|
+
@props_mutex = @props_entry[:mutex]
|
|
181
212
|
@first_token_timeout = first_token_timeout
|
|
182
213
|
@host_name = name
|
|
183
214
|
@label = name.to_s.empty? ? @transport.label : name.to_s
|
|
@@ -270,6 +301,10 @@ module Samagotchi
|
|
|
270
301
|
chunk[:finish_reason] = finish_reason if finish_reason
|
|
271
302
|
on_chunk.call(**chunk)
|
|
272
303
|
end
|
|
304
|
+
# The host answered: a /props probe that failed while it was down (or
|
|
305
|
+
# loading) is no longer true, so the next turn asks it again instead
|
|
306
|
+
# of reusing the failure for the rest of its PROPS_FAILURE_TTL.
|
|
307
|
+
clear_props_failures!
|
|
273
308
|
result
|
|
274
309
|
rescue LLM::BadRequest => e
|
|
275
310
|
raise unless e.status == 404 && @transport.name == :llama_cpp
|
|
@@ -293,6 +328,7 @@ module Samagotchi
|
|
|
293
328
|
def list_models
|
|
294
329
|
uri = URI("#{@scheme}://#{@host}:#{@port}#{@transport.models_path}")
|
|
295
330
|
response = @http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" })
|
|
331
|
+
clear_props_failures!
|
|
296
332
|
parsed = JSON.parse(response.body.to_s)
|
|
297
333
|
parsed.fetch("data", parsed)
|
|
298
334
|
rescue LLM::ProviderError
|
|
@@ -309,7 +345,9 @@ module Samagotchi
|
|
|
309
345
|
# Whatever the server answers (a non-200 too) is cached per model; a
|
|
310
346
|
# network failure for PROPS_FAILURE_TTL seconds, then the next call asks
|
|
311
347
|
# again. A probe cut by this thread's Client.probe_cancel answers
|
|
312
|
-
# :cancelled, uncached, and the turn's next request ends it.
|
|
348
|
+
# :cancelled, uncached, and the turn's next request ends it. A completion
|
|
349
|
+
# or model list this client gets through clears the failures: a server
|
|
350
|
+
# that just answered is not the down or hung one the window remembered.
|
|
313
351
|
def server_props(model: nil)
|
|
314
352
|
path = @transport.props_path
|
|
315
353
|
return nil unless path
|
|
@@ -350,11 +388,12 @@ module Samagotchi
|
|
|
350
388
|
nil
|
|
351
389
|
end
|
|
352
390
|
|
|
353
|
-
# Forget cached /props answers: the server may have
|
|
354
|
-
# another -c, or a model switch may have loaded one with a
|
|
355
|
-
# window.
|
|
356
|
-
#
|
|
357
|
-
#
|
|
391
|
+
# Forget cached /props answers for this host: the server may have
|
|
392
|
+
# restarted with another -c, or a model switch may have loaded one with a
|
|
393
|
+
# different window. The host's entry is shared with every Client of the
|
|
394
|
+
# same server, so the clear reaches them all. A recent failure stays
|
|
395
|
+
# until its PROPS_FAILURE_TTL ends: it holds no stale window, and asking
|
|
396
|
+
# a hung server again at every turn's start is what it saves.
|
|
358
397
|
def invalidate_context_window!
|
|
359
398
|
@props_mutex.synchronize { @props_cache.clear }
|
|
360
399
|
end
|
|
@@ -363,6 +402,15 @@ module Samagotchi
|
|
|
363
402
|
|
|
364
403
|
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
365
404
|
|
|
405
|
+
# The host answered a request: whatever a probe failed with (refused,
|
|
406
|
+
# timed out, 503 while it loaded) is stale, so the next probe asks it
|
|
407
|
+
# again rather than serving the failure for the rest of its
|
|
408
|
+
# PROPS_FAILURE_TTL. A cached answer stays: it was read from the same
|
|
409
|
+
# running server.
|
|
410
|
+
def clear_props_failures!
|
|
411
|
+
@props_mutex.synchronize { @props_failures.clear }
|
|
412
|
+
end
|
|
413
|
+
|
|
366
414
|
def probe_props(path, model)
|
|
367
415
|
query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
|
|
368
416
|
uri = URI("#{@scheme}://#{@host}:#{@port}#{path}#{query}")
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -142,7 +142,8 @@ module Samagotchi
|
|
|
142
142
|
model_key: -> { @model_key },
|
|
143
143
|
model_name: -> { guardrail_model_name },
|
|
144
144
|
cancelled: -> { !!active_cancel_controller&.cancelled? },
|
|
145
|
-
ask: ->(fields) { open_question(fields) }
|
|
145
|
+
ask: ->(fields) { open_question(fields) },
|
|
146
|
+
notify: ->(message) { guardrail_notify(message) }
|
|
146
147
|
)
|
|
147
148
|
@guardrail_failures = @guardrail_wiring.failures
|
|
148
149
|
# Plugins that failed to load: announced apart, as plugins (not
|
|
@@ -1194,6 +1195,16 @@ module Samagotchi
|
|
|
1194
1195
|
end
|
|
1195
1196
|
private :hook_notify
|
|
1196
1197
|
|
|
1198
|
+
# A guardrails line to the user after load (:guardrail_warning, as the
|
|
1199
|
+
# load failures show): a running turn's event, else announced.
|
|
1200
|
+
def guardrail_notify(message)
|
|
1201
|
+
event = { type: :guardrail_warning, message: message.to_s }
|
|
1202
|
+
in_turn, sink = @turn_state.in_turn_sink
|
|
1203
|
+
in_turn ? emit_event(sink, event) : announce_or_hold(event)
|
|
1204
|
+
nil
|
|
1205
|
+
end
|
|
1206
|
+
private :guardrail_notify
|
|
1207
|
+
|
|
1197
1208
|
# A question through the question flow (REPL sync handler, attached TUI,
|
|
1198
1209
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
1199
1210
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
@@ -1569,13 +1580,13 @@ module Samagotchi
|
|
|
1569
1580
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
1570
1581
|
raise image_error if image_error
|
|
1571
1582
|
|
|
1572
|
-
#
|
|
1573
|
-
#
|
|
1574
|
-
#
|
|
1575
|
-
#
|
|
1576
|
-
#
|
|
1577
|
-
#
|
|
1578
|
-
|
|
1583
|
+
# The window and the profile probes run after run_turn's probe-cancel
|
|
1584
|
+
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
1585
|
+
# The cache itself was dropped at the previous turn's end, so this
|
|
1586
|
+
# turn's first probe asks the server (a restart with another -c
|
|
1587
|
+
# between turns is noticed), and the answer the session's prompt build
|
|
1588
|
+
# read for its profile (a first turn) is what this turn starts from:
|
|
1589
|
+
# one GET /props per turn, not two.
|
|
1579
1590
|
refresh_profile!
|
|
1580
1591
|
|
|
1581
1592
|
# Before anything of the turn is kept or a reminder is used up.
|
|
@@ -1828,6 +1839,13 @@ module Samagotchi
|
|
|
1828
1839
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1829
1840
|
left = @turn_state.finish!
|
|
1830
1841
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
1842
|
+
# The next turn's window and served model are asked again: the cache
|
|
1843
|
+
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
1844
|
+
# rather than at its start, so what this turn read (the profile's own
|
|
1845
|
+
# probe included) is not asked for a second time within the turn. A
|
|
1846
|
+
# restart with another -c between turns is still noticed: the next
|
|
1847
|
+
# turn's first probe goes to the server.
|
|
1848
|
+
@client.invalidate_context_window!
|
|
1831
1849
|
log_dropped_steers(left, "turn_ended")
|
|
1832
1850
|
record_activity
|
|
1833
1851
|
# Clear hooks so they remain turn-scoped and never leak into the next turn.
|
|
@@ -5,6 +5,8 @@ require "yaml"
|
|
|
5
5
|
require_relative "config"
|
|
6
6
|
require_relative "log"
|
|
7
7
|
require_relative "guardrails"
|
|
8
|
+
require_relative "memory_bundle/manifest"
|
|
9
|
+
require_relative "version"
|
|
8
10
|
require_relative "hooks"
|
|
9
11
|
require_relative "edit_preview"
|
|
10
12
|
require_relative "tool_activity"
|
|
@@ -32,7 +34,10 @@ module Samagotchi
|
|
|
32
34
|
# @param model_name [#call] → String, nil: the effective model's bare name
|
|
33
35
|
# @param cancelled [#call] → Boolean, whether the running turn is cancelled
|
|
34
36
|
# @param ask [#call] (fields) → the answer; the Engine's question flow
|
|
35
|
-
|
|
37
|
+
# @param notify [#call] (message) → shows a guardrails line in every UI
|
|
38
|
+
# (a bundle's rules kept because it needs a newer chi)
|
|
39
|
+
def initialize(scratch:, hooks:, tools:, session:, model_key:, cancelled:, ask:, model_name: -> {},
|
|
40
|
+
notify: ->(_message) {})
|
|
36
41
|
@scratch = scratch
|
|
37
42
|
@hooks_lookup = hooks
|
|
38
43
|
@tools_lookup = tools
|
|
@@ -41,6 +46,11 @@ module Samagotchi
|
|
|
41
46
|
@model_name_lookup = model_name
|
|
42
47
|
@cancelled_lookup = cancelled
|
|
43
48
|
@ask = ask
|
|
49
|
+
@notify = notify
|
|
50
|
+
# Bundle name → the rules last loaded from it whole, kept while the
|
|
51
|
+
# installed version needs a newer chi; [name, version] said so once.
|
|
52
|
+
@loaded_bundle_rules = {}
|
|
53
|
+
@kept_noticed = {}
|
|
44
54
|
@failures = Guardrails::LoadFailures.new
|
|
45
55
|
@rules_mutex = Mutex.new
|
|
46
56
|
@git = Guardrails::GitInfo.new
|
|
@@ -115,7 +125,9 @@ module Samagotchi
|
|
|
115
125
|
|
|
116
126
|
# Installed bundles' guardrails/*.yml, by bundle name then file name.
|
|
117
127
|
# A file that is missing, changed since install (sha256) or doesn't
|
|
118
|
-
# parse is a required load failure.
|
|
128
|
+
# parse is a required load failure. A bundle whose requires_chi this
|
|
129
|
+
# chi doesn't meet isn't read (its rules may use keys this chi doesn't
|
|
130
|
+
# know): the rules last loaded from it stay (#too_new_bundle_rules).
|
|
119
131
|
def bundle_rules
|
|
120
132
|
require_relative "memory_bundle/provenance"
|
|
121
133
|
rules = []
|
|
@@ -125,7 +137,13 @@ module Samagotchi
|
|
|
125
137
|
@failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
|
|
126
138
|
next
|
|
127
139
|
end
|
|
140
|
+
if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
|
|
141
|
+
rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
|
|
142
|
+
next
|
|
143
|
+
end
|
|
128
144
|
dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
|
|
145
|
+
loaded = []
|
|
146
|
+
whole = true
|
|
129
147
|
data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
|
|
130
148
|
what = "rules #{basename} (bundle #{bundle_name})"
|
|
131
149
|
path = File.join(dir, basename.to_s)
|
|
@@ -139,12 +157,15 @@ module Samagotchi
|
|
|
139
157
|
doc = YAML.safe_load(File.read(path))
|
|
140
158
|
raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
|
|
141
159
|
|
|
142
|
-
|
|
160
|
+
loaded.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
|
|
143
161
|
rescue Guardrails::Rules::ParseError, Psych::Exception => e
|
|
144
162
|
Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
|
|
145
163
|
@failures.add(what, e.message, required: true, group: :rules)
|
|
164
|
+
whole = false
|
|
146
165
|
end
|
|
147
166
|
end
|
|
167
|
+
rules.concat(loaded)
|
|
168
|
+
@loaded_bundle_rules[bundle_name] = loaded if whole
|
|
148
169
|
end
|
|
149
170
|
rules
|
|
150
171
|
rescue StandardError => e
|
|
@@ -192,6 +213,38 @@ module Samagotchi
|
|
|
192
213
|
|
|
193
214
|
private
|
|
194
215
|
|
|
216
|
+
# The rules of a bundle that needs a newer chi: the ones last loaded
|
|
217
|
+
# from it, with one notice per bundle version (restart the session to
|
|
218
|
+
# load the new ones). Never loaded (a chi older than the bundle it
|
|
219
|
+
# starts with): none, and a required load failure, as the hook loader
|
|
220
|
+
# does for a guardrail hook, so the calls aren't quietly unguarded.
|
|
221
|
+
def too_new_bundle_rules(bundle_name, data, reason)
|
|
222
|
+
kept = @loaded_bundle_rules[bundle_name]
|
|
223
|
+
unless kept
|
|
224
|
+
Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
|
|
225
|
+
bundle: bundle_name)
|
|
226
|
+
@failures.add("rules (bundle #{bundle_name})", "#{reason}; update chi (chi update) or uninstall the bundle",
|
|
227
|
+
required: true, group: :rules)
|
|
228
|
+
return []
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
key = [bundle_name, data[:version].to_s]
|
|
232
|
+
return kept if @kept_noticed[key]
|
|
233
|
+
|
|
234
|
+
@kept_noticed[key] = true
|
|
235
|
+
Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
|
|
236
|
+
requires_chi: data[:requires_chi].to_s)
|
|
237
|
+
@notify.call(kept_notice(bundle_name, data))
|
|
238
|
+
kept
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def kept_notice(bundle_name, data)
|
|
242
|
+
id = @session_lookup.call&.id || "ID"
|
|
243
|
+
"bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
|
|
244
|
+
"its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
|
|
245
|
+
"(chi sessions stop #{id}, then chi --resume #{id})"
|
|
246
|
+
end
|
|
247
|
+
|
|
195
248
|
# [path, mtime, size] of config.yml and every installed bundle's
|
|
196
249
|
# manifest.json and guardrails/ file.
|
|
197
250
|
def rules_stamp
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -168,7 +168,7 @@ module Samagotchi
|
|
|
168
168
|
|
|
169
169
|
# Send +request+ and return the response with its body read.
|
|
170
170
|
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
171
|
-
# is
|
|
171
|
+
# is; either way Net::HTTP's own silent retry is off (see #start)
|
|
172
172
|
# @param check_status [Boolean] false: return an error response instead
|
|
173
173
|
# of raising its ProviderError
|
|
174
174
|
# @raise [RequestCancelled] when +cancel_controller+ cancels (with or
|
|
@@ -178,7 +178,7 @@ module Samagotchi
|
|
|
178
178
|
identify(request)
|
|
179
179
|
current = new_attempt_state(uri, request, log_fields, stream: false)
|
|
180
180
|
attempt = lambda do |state|
|
|
181
|
-
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries:
|
|
181
|
+
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
|
|
182
182
|
state[:http] = http
|
|
183
183
|
http.request(request).tap do |response|
|
|
184
184
|
state[:status] = response.code.to_i
|
|
@@ -210,7 +210,13 @@ module Samagotchi
|
|
|
210
210
|
|
|
211
211
|
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
212
212
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
213
|
-
|
|
213
|
+
# Net::HTTP retries an idempotent request once on its own (max_retries
|
|
214
|
+
# defaults to 1) when the connection drops before an answer: chi's
|
|
215
|
+
# RetryPolicy owns retries (and the log line says how many were sent),
|
|
216
|
+
# so every request here gets Net::HTTP's silent one off. Callers may
|
|
217
|
+
# still ask for it explicitly (a fetch whose retries: false passes 0).
|
|
218
|
+
retries = max_retries || 0
|
|
219
|
+
options[:max_retries] = retries
|
|
214
220
|
options[:use_ssl] = true if uri.scheme == "https"
|
|
215
221
|
Net::HTTP.start(uri.host, uri.port, **options, &block)
|
|
216
222
|
end
|
|
@@ -112,7 +112,7 @@ module Samagotchi
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def self.default
|
|
115
|
-
|
|
115
|
+
named(DEFAULT_NAME)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def self.normalize(value)
|
|
@@ -124,7 +124,7 @@ module Samagotchi
|
|
|
124
124
|
when "gemma", "gemma4", "gemma4o"
|
|
125
125
|
gemma4
|
|
126
126
|
else
|
|
127
|
-
|
|
127
|
+
named(DEFAULT_NAME)
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
|
|
@@ -45,13 +45,17 @@ module Samagotchi
|
|
|
45
45
|
)
|
|
46
46
|
INDIVIDUAL_RE.freeze
|
|
47
47
|
|
|
48
|
-
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
49
|
-
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
50
|
-
# are removed as a unit.
|
|
51
48
|
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
52
49
|
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
53
50
|
# are removed as a unit. Pipes are escaped; angle brackets are literal.
|
|
54
|
-
LITERALS_RE = /<\|"
|
|
51
|
+
LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
|
|
52
|
+
|
|
53
|
+
# Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
|
|
54
|
+
# `<channel|>` close, or to the end of the text when the close is missing
|
|
55
|
+
# (ToolCallParser::Gemma strips the same pair). Removing only the marker
|
|
56
|
+
# would leave the model's private reasoning in the answer; the channel is
|
|
57
|
+
# the profile's thinking lane, not output.
|
|
58
|
+
GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
|
|
55
59
|
|
|
56
60
|
# Qwen prompt-literal placeholders, e.g. </think>.
|
|
57
61
|
PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
|
|
@@ -88,6 +92,7 @@ module Samagotchi
|
|
|
88
92
|
def remove_tokens(text)
|
|
89
93
|
text.to_s
|
|
90
94
|
.gsub(QWEN_THINK_RE, '')
|
|
95
|
+
.gsub(GEMMA_THOUGHT_RE, '')
|
|
91
96
|
.gsub(QWEN_TOOL_CALL_RE, '')
|
|
92
97
|
.gsub(LITERALS_RE, '')
|
|
93
98
|
.gsub(INDIVIDUAL_RE, '')
|
|
@@ -17,16 +17,26 @@ module Samagotchi
|
|
|
17
17
|
name.is_a?(String) && !name.strip.empty? ? name : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The separators a provider uses when it decorates a model name: a tag
|
|
21
|
+
# (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
|
|
22
|
+
# owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
|
|
23
|
+
# after the name is a different model (`gpt-4` is not `gpt-4o`).
|
|
24
|
+
EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
|
|
25
|
+
|
|
20
26
|
# Whether +served+ is another model than +asked+. Names that are equal
|
|
21
|
-
# (any case) or where one extends the other
|
|
22
|
-
# `:free`, or adding a date) are the
|
|
23
|
-
# differ.
|
|
27
|
+
# (any case), or where one extends the other at a tag/date/owner
|
|
28
|
+
# separator (a provider dropping `:free`, or adding a date), are the
|
|
29
|
+
# same model; unknown names never differ.
|
|
24
30
|
def differs?(asked, served)
|
|
25
31
|
asked = asked.to_s.strip.downcase
|
|
26
32
|
served = served.to_s.strip.downcase
|
|
27
33
|
return false if asked.empty? || served.empty?
|
|
34
|
+
return false if asked == served
|
|
35
|
+
|
|
36
|
+
shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
|
|
37
|
+
return true unless longer.start_with?(shorter)
|
|
28
38
|
|
|
29
|
-
!
|
|
39
|
+
!longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
|
|
30
40
|
end
|
|
31
41
|
end
|
|
32
42
|
end
|
|
@@ -237,6 +237,8 @@ module Samagotchi
|
|
|
237
237
|
end_turn
|
|
238
238
|
own_turn_ended(event)
|
|
239
239
|
when :prompt_restored then restore_prompt(event)
|
|
240
|
+
# The after_turn hooks are done (their notices came before it).
|
|
241
|
+
when :answer_display then @display_pending = false
|
|
240
242
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
241
243
|
# Another client's anytime command: its line now, before the cards
|
|
242
244
|
# it shows (its command_ran comes when it's done).
|
|
@@ -580,9 +582,10 @@ module Samagotchi
|
|
|
580
582
|
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
581
583
|
end
|
|
582
584
|
|
|
583
|
-
# Whether the input's end must wait: the -p prompt isn't sent yet,
|
|
584
|
-
#
|
|
585
|
-
|
|
585
|
+
# Whether the input's end must wait: the -p prompt isn't sent yet, a
|
|
586
|
+
# prompt this run sent hasn't had its turn, or its turn's after_turn
|
|
587
|
+
# hooks still run (their notices, source-links' sources:, come next).
|
|
588
|
+
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
|
|
586
589
|
|
|
587
590
|
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
588
591
|
def own_turn_ended(event)
|
|
@@ -592,6 +595,8 @@ module Samagotchi
|
|
|
592
595
|
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
593
596
|
@own_failed = true if ours && event[:type] == :turn_failed
|
|
594
597
|
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
598
|
+
# The worker sends answer_display once its after_turn hooks ran.
|
|
599
|
+
@display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
|
|
595
600
|
@open_ids.delete(id)
|
|
596
601
|
@open_ids.subtract(@merged_ids)
|
|
597
602
|
@merged_ids.clear
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
require_relative "event_renderer"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The turn sink of `chi -p … --non-interactive` (Engine#run_turn's
|
|
9
|
+
# on_event): the few lines a reader wants while the turn runs, on
|
|
10
|
+
# stderr. stdout is the answer's alone, printed after the turn: a
|
|
11
|
+
# script or a parent agent reads it.
|
|
12
|
+
#
|
|
13
|
+
# It prints the retry lines the REPL shows: the loop asking again after
|
|
14
|
+
# an empty answer (:empty_answer_retry), and a generation retried after
|
|
15
|
+
# a failed request (:generation_retrying). And the hooks' notices
|
|
16
|
+
# (:hook_notice): one during the turn as it comes, one the after_turn
|
|
17
|
+
# hooks give (after :turn_completed; source-links' `sources:`) kept for
|
|
18
|
+
# #flush, which the caller runs after it has printed the answer.
|
|
19
|
+
class OneShotSink
|
|
20
|
+
include Formatting
|
|
21
|
+
|
|
22
|
+
# @param err [IO, nil] nil writes to $stderr as it is at each write
|
|
23
|
+
def initialize(err: nil)
|
|
24
|
+
@err = err
|
|
25
|
+
@turn_over = false
|
|
26
|
+
@held = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(event)
|
|
30
|
+
case event[:type]
|
|
31
|
+
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
|
+
when :generation_retrying then line(format_generation_retry_line(event))
|
|
33
|
+
when :turn_completed, :turn_canceled then @turn_over = true
|
|
34
|
+
when :hook_notice
|
|
35
|
+
text = EventRenderer.hook_notice_line(event)
|
|
36
|
+
@turn_over ? @held << text : line(text)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Print the notices that came after the turn's end.
|
|
41
|
+
def flush
|
|
42
|
+
line(@held.shift) until @held.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def line(text)
|
|
48
|
+
err.puts(text)
|
|
49
|
+
err.flush
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def err = @err || $stderr
|
|
53
|
+
|
|
54
|
+
# Colour when stderr, where the lines go, is a colour terminal.
|
|
55
|
+
def color_output?
|
|
56
|
+
return false unless err.tty?
|
|
57
|
+
return false if ENV.key?("NO_COLOR")
|
|
58
|
+
|
|
59
|
+
ENV.fetch("TERM", "") != "dumb"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -30,6 +30,7 @@ require_relative "terminal_ui/input_support"
|
|
|
30
30
|
require_relative "terminal_ui/image_input"
|
|
31
31
|
require_relative "terminal_ui/plain_surface"
|
|
32
32
|
require_relative "terminal_ui/live_region"
|
|
33
|
+
require_relative "terminal_ui/one_shot_sink"
|
|
33
34
|
require_relative "terminal_ui/question_prompt"
|
|
34
35
|
require_relative "terminal_ui/repl_input"
|
|
35
36
|
require_relative "terminal_ui/status_row"
|
|
@@ -210,7 +211,9 @@ module Samagotchi
|
|
|
210
211
|
# follow-up REPL (--non-interactive) or drops into the REPL carrying the
|
|
211
212
|
# post-turn conversation.
|
|
212
213
|
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
213
|
-
# 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)
|
|
214
217
|
def run
|
|
215
218
|
# --non-interactive with no --prompt is a harmless no-op exit: build
|
|
216
219
|
# nothing and return (no transient session, no banner).
|
|
@@ -240,25 +243,36 @@ module Samagotchi
|
|
|
240
243
|
if @prompt && @non_interactive
|
|
241
244
|
# Headless / CI mode: run directly without TTY rendering.
|
|
242
245
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
251
262
|
# No answer (the empty-answer retries used up): no text, or the chat
|
|
252
263
|
# loop's placeholder (LLM::ModelResult#empty_answer?).
|
|
253
264
|
if result.empty_answer?
|
|
254
|
-
# Nothing to print: say so on stderr and fail
|
|
255
|
-
# 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.
|
|
256
267
|
session.save
|
|
257
268
|
warn EMPTY_ANSWER_ERROR
|
|
258
|
-
|
|
269
|
+
sink.flush
|
|
270
|
+
return :empty_answer
|
|
259
271
|
end
|
|
260
272
|
@surface.commit(result.output)
|
|
261
273
|
session.save
|
|
274
|
+
# The after_turn hooks' notices, after the answer as the REPL shows them.
|
|
275
|
+
sink.flush
|
|
262
276
|
return
|
|
263
277
|
end
|
|
264
278
|
|
|
@@ -286,6 +300,18 @@ module Samagotchi
|
|
|
286
300
|
|
|
287
301
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
288
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
|
+
|
|
289
315
|
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
290
316
|
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
291
317
|
# the command changed it.
|
|
@@ -302,6 +328,19 @@ module Samagotchi
|
|
|
302
328
|
:turn_failed if result.status == :error
|
|
303
329
|
end
|
|
304
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
|
+
|
|
305
344
|
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
306
345
|
# a one-shot or on the way out of an error or a signal.
|
|
307
346
|
def delete_scratch(session, quiet:)
|
|
@@ -400,19 +439,29 @@ module Samagotchi
|
|
|
400
439
|
system_message = { role: "system", content: seed_system_prompt }
|
|
401
440
|
if @resume_session
|
|
402
441
|
messages = ContextNote.with_system_head(session.messages.dup, system_message)
|
|
403
|
-
|
|
442
|
+
banner("Resumed session: #{session.id}")
|
|
404
443
|
saved = @engine.saved_recap
|
|
405
|
-
|
|
444
|
+
banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
|
|
406
445
|
elsif @scratch
|
|
407
446
|
messages = [system_message]
|
|
408
|
-
|
|
447
|
+
banner("Scratch session: nothing is kept, it is deleted when you leave.")
|
|
409
448
|
else
|
|
410
449
|
messages = [system_message]
|
|
411
|
-
|
|
450
|
+
banner("Session: #{session.id}")
|
|
412
451
|
end
|
|
413
452
|
messages
|
|
414
453
|
end
|
|
415
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
|
+
|
|
416
465
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
417
466
|
# threaded in here (so --prompt / --resume share one code path). The
|
|
418
467
|
# session's conversation is the single working copy: turns go through
|
|
@@ -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: {
|
|
@@ -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: {
|
|
@@ -21,6 +21,15 @@ module Samagotchi
|
|
|
21
21
|
WAIT_SLICE_SEC = 0.2
|
|
22
22
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
23
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
|
|
24
33
|
|
|
25
34
|
def self.name = NAME
|
|
26
35
|
|
|
@@ -35,7 +44,7 @@ module Samagotchi
|
|
|
35
44
|
return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
|
|
36
45
|
|
|
37
46
|
timeout_sec = timeout_seconds
|
|
38
|
-
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,
|
|
39
48
|
env: env)
|
|
40
49
|
|
|
41
50
|
stdout_block = output_block("stdout", stdout)
|
|
@@ -50,6 +59,7 @@ module Samagotchi
|
|
|
50
59
|
# model as "done, something happened".
|
|
51
60
|
silent = stdout_block.nil? && stderr_block.nil?
|
|
52
61
|
parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
|
|
62
|
+
parts << (held_open == :detached ? BACKGROUND_DETACHED_HINT : BACKGROUND_HINT) if held_open
|
|
53
63
|
parts.join("\n")
|
|
54
64
|
rescue CommandTimedOut => e
|
|
55
65
|
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
@@ -75,13 +85,14 @@ module Samagotchi
|
|
|
75
85
|
status = nil
|
|
76
86
|
timed_out = false
|
|
77
87
|
was_cancelled = false
|
|
88
|
+
aborted = false
|
|
89
|
+
held_open = false
|
|
78
90
|
started = monotonic_time
|
|
79
91
|
deadline = started + timeout_sec
|
|
80
92
|
|
|
81
93
|
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
82
94
|
stdin.close
|
|
83
|
-
|
|
84
|
-
stderr_reader = reader_thread_for(stderr)
|
|
95
|
+
readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
|
|
85
96
|
|
|
86
97
|
begin
|
|
87
98
|
until wait_thr.join(WAIT_SLICE_SEC)
|
|
@@ -100,36 +111,103 @@ module Samagotchi
|
|
|
100
111
|
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
101
112
|
# process group, so it never saw the SIGINT. Kill it, or the reads
|
|
102
113
|
# below block until it closes stdout.
|
|
114
|
+
aborted = true
|
|
103
115
|
terminate_process_tree(wait_thr.pid)
|
|
104
116
|
raise
|
|
105
117
|
ensure
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
|
114
134
|
end
|
|
115
135
|
end
|
|
116
136
|
|
|
117
137
|
raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
|
|
118
138
|
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
|
|
119
139
|
|
|
120
|
-
[stdout_text, stderr_text, status]
|
|
140
|
+
[stdout_text, stderr_text, status, held_open]
|
|
121
141
|
end
|
|
122
142
|
private_class_method :run_command
|
|
123
143
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
130
208
|
end
|
|
131
209
|
end
|
|
132
|
-
|
|
210
|
+
private_constant :OutputReader
|
|
133
211
|
|
|
134
212
|
def self.close_quietly(io)
|
|
135
213
|
io.close unless io.closed?
|
data/lib/samagotchi/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.18.
|
|
4
|
+
version: 0.18.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -352,6 +352,7 @@ files:
|
|
|
352
352
|
- lib/samagotchi/terminal_ui/input_support.rb
|
|
353
353
|
- lib/samagotchi/terminal_ui/line_reader.rb
|
|
354
354
|
- lib/samagotchi/terminal_ui/live_region.rb
|
|
355
|
+
- lib/samagotchi/terminal_ui/one_shot_sink.rb
|
|
355
356
|
- lib/samagotchi/terminal_ui/plain_surface.rb
|
|
356
357
|
- lib/samagotchi/terminal_ui/question_prompt.rb
|
|
357
358
|
- lib/samagotchi/terminal_ui/question_slot.rb
|