samagotchi 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -1
- data/docs/cli.md +6 -1
- data/docs/configuration.md +9 -0
- data/docs/hooks.md +20 -6
- data/docs/plugins.md +1 -1
- data/docs/sub-agent.md +4 -3
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +7 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +80 -1
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +111 -3
- data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
- data/lib/samagotchi/idle_recap.rb +4 -2
- data/lib/samagotchi/kernel_loop.rb +19 -9
- data/lib/samagotchi/llm/chat_loop.rb +20 -10
- data/lib/samagotchi/llm/openai_chat.rb +3 -0
- data/lib/samagotchi/log_subscriber.rb +3 -2
- data/lib/samagotchi/prompt.rb +4 -1
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/thought_stream_splitter.rb +9 -3
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +40 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +28 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/index.html +8 -0
- data/lib/samagotchi/web/public/stage_model.js +11 -4
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/worker.rb +4 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d6348260d3f666ffbfc0b8c3f10a51a2c64c3a30ce9cbd5a5c1280fa0325306
|
|
4
|
+
data.tar.gz: 0cad64155e27a000a49c82e89778d69804fc609526418c1e03846a32d2fb864c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9a1f022f8a225c517e3160d0d512fbb5a97750c32a6df7769bbcb91ddff5534c5b12f540c4ef80871d8e4f2f80d460c71cd56107476435a69d54cef0ef78395
|
|
7
|
+
data.tar.gz: 419a527ffca2330de67b42544f619db872c06388ded821d3bc5e7f17f07a488a7adf96ce56d55032a53c4bc21e6076d32af398c0b8aff15c48e651e6596e7873
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,44 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.24.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `steer.cut_after` (default 20 s, `0` = never): a message for a running turn from you, `chi send` or a parent
|
|
16
|
+
agent cuts a generation that has streamed only thinking for that long (counted from its first thinking token;
|
|
17
|
+
a message that came earlier cuts once the thinking gets there).
|
|
18
|
+
The model starts the step again with the message, and a `↪ cut in for your message` row marks it. Plugins'
|
|
19
|
+
steers never cut. Only the cut thinking is lost (llama.cpp re-reads the few tokens after its cached prompt).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The web shows an `execute` / `task_create` call's full command: an expanded tool row has it as a block above the
|
|
24
|
+
output (whitespace kept, a copy button, `in <cwd>` when the call gave one), and the row's and the stage's hovers
|
|
25
|
+
show the whole command instead of the 80-character `command="…"` line. The terminal is unchanged.
|
|
26
|
+
- A message that joins a running turn (a line typed in the terminal or the web, `chi send -m`, a delegate's
|
|
27
|
+
follow-up, `chi answer --option Continue --text`, a plugin's `ctx.steer`) reaches the model with a one-line
|
|
28
|
+
header naming its sender and asking it to follow the message, or carry on if it asks for nothing, so the model
|
|
29
|
+
can tell who is steering it. Sessions keep the raw text.
|
|
30
|
+
- The `generation_stopped` log line names who cut the generation as `by=` (was `bundle=`), as a message can cut
|
|
31
|
+
it now too.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Guardrails read heredocs (`cat <<'EOF' … EOF`): a heredoc's body no longer asks needlessly (an `rm -rf` or
|
|
36
|
+
`git push` written into a file by `cat`/`tee`), and an apostrophe in a body no longer hides the commands after it
|
|
37
|
+
from the strict-mode check for git outside the repo. A body fed to `bash`, `sh` and the like still counts.
|
|
38
|
+
- A `chi send -m` or delegate follow-up merged into a running turn is saved with its sender (`source: chi_send` /
|
|
39
|
+
`parent_agent`) instead of reading as the user's own words.
|
|
40
|
+
- The idle recap keeps the text of a `chi answer --option Continue --text` (or a continue card answered with
|
|
41
|
+
text); it was dropped as a plugin's prod.
|
|
42
|
+
- check-in no longer says a nudge was not sent when a user line was merged after it (bundle check-in 0.2.3).
|
|
43
|
+
- A plugin's cut of a generation (loop-guard) with a message or a plugin's steer waiting now delivers it even after
|
|
44
|
+
`retry.empty_answer`'s budget is spent; the turn used to end cancelled and drop a waiting plugin steer.
|
|
45
|
+
|
|
46
|
+
Update with `chi update`: check-in moves to 0.2.3 (the nudge-not-sent fix). Restart `chi web` and running sessions
|
|
47
|
+
afterwards (`chi sessions restart ID`).
|
|
48
|
+
|
|
11
49
|
## [0.23.0] - 2026-10-04
|
|
12
50
|
|
|
13
51
|
### Changed
|
|
@@ -1304,7 +1342,8 @@ and long-lived sessions.
|
|
|
1304
1342
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1305
1343
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1306
1344
|
|
|
1307
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1345
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.24.0...HEAD
|
|
1346
|
+
[0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
|
|
1308
1347
|
[0.23.0]: https://github.com/dm1try/samagotchi/compare/v0.22.0...v0.23.0
|
|
1309
1348
|
[0.22.0]: https://github.com/dm1try/samagotchi/compare/v0.21.0...v0.22.0
|
|
1310
1349
|
[0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
|
data/docs/cli.md
CHANGED
|
@@ -274,7 +274,12 @@ Plain `chi` runs the session in a background worker and attaches the terminal
|
|
|
274
274
|
to it (`session.shared`, default `true`). A worker's session can have any number
|
|
275
275
|
of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
|
|
276
276
|
`--attach`). They all see the same turns as they happen, and any of them can send
|
|
277
|
-
a prompt, also while a turn runs (it merges into that turn as steering
|
|
277
|
+
a prompt, also while a turn runs (it merges into that turn as steering: the model
|
|
278
|
+
is told it is a steer and who sent it). Steering waits for the model's next step,
|
|
279
|
+
unless the model has streamed only thinking for `steer.cut_after` seconds (default
|
|
280
|
+
20), now or while your message waits: then that generation is cut and the step starts again with your message, marked
|
|
281
|
+
`↪ cut in for your message` (see
|
|
282
|
+
[configuration.md](configuration.md#llama-network-retry-behavior)). The
|
|
278
283
|
first answer to an `ask_user_question` wins; the other UIs close their widget.
|
|
279
284
|
An empty answer dismisses the question in every UI. The model is then told
|
|
280
285
|
not to go ahead with what it asked about, or change anything else, and to wait.
|
data/docs/configuration.md
CHANGED
|
@@ -646,6 +646,14 @@ no made-up answer is saved.
|
|
|
646
646
|
A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
|
|
647
647
|
[hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
|
|
648
648
|
none left the turn ends as cancelled (hook).
|
|
649
|
+
A message for a running turn waits for the next step, unless the model's generation has streamed only thinking for
|
|
650
|
+
`steer.cut_after` seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first
|
|
651
|
+
thinking token, so the wait for a busy server doesn't count) and still does: then a message from you (the terminal,
|
|
652
|
+
the web), `chi send` or a parent agent cuts that generation, and the model starts the step again with the message
|
|
653
|
+
(`↪ cut in for your message`). A message that comes earlier cuts once the thinking passes `steer.cut_after`, unless
|
|
654
|
+
the step ends first and the message goes in there. Such a cut spends no `retry.empty_answer` attempt and sends no
|
|
655
|
+
hidden note; only the cut thinking is lost. A generation that has streamed visible text or a tool call is never cut,
|
|
656
|
+
and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
|
|
649
657
|
|
|
650
658
|
Assist-mode UX:
|
|
651
659
|
|
|
@@ -887,6 +895,7 @@ described in their own sections.
|
|
|
887
895
|
| `retry.base_delay` | `0.5` | yes | |
|
|
888
896
|
| `retry.max_delay` | `8.0` | yes | |
|
|
889
897
|
| `retry.empty_answer` | `1` | | Times a turn asks again after an empty answer (at most 3, `0` = off). See "Llama Network Retry Behavior". |
|
|
898
|
+
| `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message for the running turn (yours, `chi send`'s, a parent agent's) cuts it; `0` = never. See "Llama Network Retry Behavior". |
|
|
890
899
|
| `turn.max_iterations` | `100` | | A turn's step limit: model ↔ tool rounds before it stops and asks to continue (an integer ≥ 1). `--no-interrupt` turns get the larger of 1000 and this. See [CLI: Iteration Limit Behavior](cli.md#iteration-limit-behavior). |
|
|
891
900
|
| `turn.parent_continue` | `true` | | Whether a parent agent (`chi answer`) may answer Continue to a session's step-limit question; `false`: Stop only. config.yml only: no environment variable. See [Sub-agent](sub-agent.md) and [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
|
|
892
901
|
| `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
|
data/docs/hooks.md
CHANGED
|
@@ -115,10 +115,16 @@ known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
|
|
|
115
115
|
`turn hook` for one registered at runtime.
|
|
116
116
|
|
|
117
117
|
A steer is saved in the session as `{role: "user", kind: "steer", source:
|
|
118
|
-
"<bundle>", content: "…"}
|
|
118
|
+
"<bundle>", content: "…"}`, its text raw. The model reads it as a user turn
|
|
119
|
+
led by one line naming the sender: `[Steer from the <bundle> plugin,
|
|
120
|
+
mid-task. Follow it; if it asks for nothing, carry on with the task.]`.
|
|
119
121
|
Its `source` is the hook's bundle (a config or turn hook's label otherwise).
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
Lines typed into a running turn are saved as `{role: "user", kind: "input",
|
|
123
|
+
content: "…"}`: part of that turn, not a turn of their own. They carry a
|
|
124
|
+
`source` when they are not the user's own: `chi_send` (`chi send -m`),
|
|
125
|
+
`parent_agent` (a delegate's follow-up) or `plugin_send` (`ctx.sessions.send`).
|
|
126
|
+
The model reads them with the same kind of header (`[Steer from the user, …]`,
|
|
127
|
+
`[Steer sent with chi send, …]`, and so on).
|
|
122
128
|
|
|
123
129
|
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
124
130
|
end line. A question from `:before_tool_call` shows **before** the tool
|
|
@@ -172,19 +178,27 @@ What a cut is, in detail:
|
|
|
172
178
|
|
|
173
179
|
- It is an empty answer made early: it uses the `retry.empty_answer` budget
|
|
174
180
|
([configuration.md](configuration.md#llama-network-retry-behavior)). With
|
|
175
|
-
no retry left (`retry.empty_answer: 0`, or already used)
|
|
176
|
-
cancelled (hook): "✕ turn canceled (hook)".
|
|
181
|
+
no retry left (`retry.empty_answer: 0`, or already used) and nothing
|
|
182
|
+
queued, the turn ends cancelled (hook): "✕ turn canceled (hook)".
|
|
177
183
|
- It shows nothing by itself: post your own notice (`event[:notify]`) to say
|
|
178
184
|
why. The bundle and the reason go into the note the model reads and the
|
|
179
185
|
log (`generation_stopped`).
|
|
180
186
|
- Queued input (the user's line, or a hook's `steer`) goes in place of the
|
|
181
|
-
hidden note,
|
|
187
|
+
hidden note, also once the retry budget is spent, and spends no retry;
|
|
188
|
+
then there is no `↻` line.
|
|
182
189
|
- The cut generation is not kept: its thinking and any visible text it had
|
|
183
190
|
streamed go with it (the retry answers anew). `:after_generation` doesn't
|
|
184
191
|
fire for it, and its token usage is lost (the stream never sent its last
|
|
185
192
|
chunk). A client that joins the running turn still sees its thinking in
|
|
186
193
|
the turn so far until the turn ends.
|
|
187
194
|
- A Stop from the user right after a cut is a plain cancel.
|
|
195
|
+
- A cut for a message (a user's, `chi send`'s or a parent agent's line into
|
|
196
|
+
a generation that has streamed only thinking for `steer.cut_after` seconds)
|
|
197
|
+
is not a plugin's cut: it sends no hidden note and spends no retry; the
|
|
198
|
+
message goes in and the UIs print `↪ cut in for your message`. The
|
|
199
|
+
stream's `generation_completed` says `stopped_by: "steer"`, and
|
|
200
|
+
`stop_generation` returns false for a generation a message already cut.
|
|
201
|
+
A plugin's `steer` never cuts.
|
|
188
202
|
|
|
189
203
|
## Presenting the answer (display only)
|
|
190
204
|
|
data/docs/plugins.md
CHANGED
|
@@ -490,7 +490,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
490
490
|
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
491
491
|
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
|
|
492
492
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
493
|
-
| `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
|
|
493
|
+
| `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. The model reads it with a header naming your bundle (`[Steer from the my-bundle plugin, mid-task. …]`); the session keeps the raw text. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
|
|
494
494
|
| `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
|
|
495
495
|
| `ctx.stop_generation(reason)` | cut the generation that is streaming: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
|
|
496
496
|
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
data/docs/sub-agent.md
CHANGED
|
@@ -60,9 +60,10 @@ answers waits as a question of kind `continue` (`"limit": 100`, options
|
|
|
60
60
|
`Continue` and `Stop`). `answer_with` continues it:
|
|
61
61
|
`chi answer ID --question QID --option Continue`, and the continued turn's
|
|
62
62
|
reply (or its next question) comes back on that same wait. With
|
|
63
|
-
`--text "…"` the text joins the continued turn as a steer
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
`--text "…"` the text joins the continued turn as a steer, labelled for the
|
|
64
|
+
model as coming from the parent agent that started the session, so one answer
|
|
65
|
+
continues and redirects it. (A `chi send -m` into a running turn joins it too,
|
|
66
|
+
labelled as sent with chi send.) To stop it:
|
|
66
67
|
`--option Stop`, with `--text "why"` for the model;
|
|
67
68
|
that wait ends with `status: not_continued`, exit 0, and the turn's work so far
|
|
68
69
|
stays in the session. A message instead (`chi send ID -m "…"`) drops the
|
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# iteration started before it (a before_tool_call hook's notice comes
|
|
17
17
|
# before its call's row, so the web puts it above row calls + 1). The
|
|
18
18
|
# loop's "asking again" row (:empty_answer_retry, after an empty or cut
|
|
19
|
-
# answer)
|
|
19
|
+
# answer) and a steer's cut row (:steer_cut) are kept the same way, and so is a question the turn asked
|
|
20
20
|
# (:question_requested), with how it was answered or cancelled, so a
|
|
21
21
|
# reload draws the resolved card where it was.
|
|
22
22
|
#
|
|
@@ -46,11 +46,11 @@ module Samagotchi
|
|
|
46
46
|
# onto a new worker doesn't print those again).
|
|
47
47
|
class CardStore
|
|
48
48
|
CAPACITY = 20
|
|
49
|
-
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
|
|
49
|
+
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
|
|
50
50
|
FILE = "cards.json"
|
|
51
51
|
# The events after which the file is written again: the ones that
|
|
52
52
|
# change an entry or the turn count.
|
|
53
|
-
SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
|
|
53
|
+
SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut question_requested question_answered question_cancelled
|
|
54
54
|
question_relay turn_failed] + Events::TURN_KEPT).freeze
|
|
55
55
|
|
|
56
56
|
# What a session's saved file lists, as a store started from it lists
|
|
@@ -167,6 +167,7 @@ module Samagotchi
|
|
|
167
167
|
when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
|
|
168
168
|
.compact.merge(in_turn: false, turns: @turns_done))
|
|
169
169
|
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by)) if @running
|
|
170
|
+
when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
|
|
170
171
|
when :question_requested
|
|
171
172
|
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
|
|
172
173
|
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
@@ -158,7 +158,8 @@ module Samagotchi
|
|
|
158
158
|
call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
|
|
159
159
|
call[:label] = part[:label] if part[:label]
|
|
160
160
|
title = part[:title] ? { title: part[:title] } : {}
|
|
161
|
-
|
|
161
|
+
view = part[:view] ? { view: part[:view] } : {}
|
|
162
|
+
events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
|
|
162
163
|
if part[:status] == "running"
|
|
163
164
|
events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
|
|
164
165
|
return events
|
|
@@ -170,6 +171,7 @@ module Samagotchi
|
|
|
170
171
|
activity: activity }
|
|
171
172
|
completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
|
|
172
173
|
completed[:diff] = part[:diff] if part[:diff]
|
|
174
|
+
completed.merge!(view)
|
|
173
175
|
events << with_images(completed, part[:images])
|
|
174
176
|
end
|
|
175
177
|
private_class_method :tool_replay_events
|
|
@@ -253,6 +255,7 @@ module Samagotchi
|
|
|
253
255
|
tool: event[:tool], params: event[:params], status: "running" }
|
|
254
256
|
part[:label] = event[:label] if event[:label]
|
|
255
257
|
part[:title] = event[:title] if event[:title]
|
|
258
|
+
part[:view] = event[:view] if event[:view]
|
|
256
259
|
parts << part
|
|
257
260
|
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
258
261
|
when :tool_call_completed
|
|
@@ -12,6 +12,7 @@ module Samagotchi
|
|
|
12
12
|
FIELDS = {
|
|
13
13
|
hook_notice: %i[hook text level],
|
|
14
14
|
empty_answer_retry: %i[iteration attempt of stopped_by],
|
|
15
|
+
steer_cut: %i[iteration source],
|
|
15
16
|
question_requested: %i[pending_question],
|
|
16
17
|
question_answered: %i[id answer],
|
|
17
18
|
question_cancelled: %i[id reason]
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "session"
|
|
|
20
20
|
require_relative "engine"
|
|
21
21
|
require_relative "guardrails/parent_approvals"
|
|
22
22
|
require_relative "session_commands"
|
|
23
|
+
require_relative "steer"
|
|
23
24
|
require_relative "image_store"
|
|
24
25
|
require_relative "log"
|
|
25
26
|
require_relative "version"
|
|
@@ -780,6 +781,8 @@ module Samagotchi
|
|
|
780
781
|
end
|
|
781
782
|
|
|
782
783
|
enqueued_id = SecureRandom.uuid
|
|
784
|
+
# Before it is queued: a drain after this may have taken it (#cut_for_steer).
|
|
785
|
+
epoch = @engine.input_epoch
|
|
783
786
|
# Write and announce with the event log held: the worker can't emit
|
|
784
787
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
785
788
|
# :turn_enqueued, and a failed write announces nothing.
|
|
@@ -799,6 +802,10 @@ module Samagotchi
|
|
|
799
802
|
return deadline_passed("turn") if enqueued == :expired
|
|
800
803
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
801
804
|
|
|
805
|
+
# Queued (outside the event lock): a running generation that has only
|
|
806
|
+
# been thinking for long is cut for it, unless a plugin sent it. An
|
|
807
|
+
# image input never merges mid-turn, so it never cuts.
|
|
808
|
+
@engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
|
|
802
809
|
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
|
|
803
810
|
end
|
|
804
811
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.3
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:821aea2821df12f278db9a94f8150244e67bf9453735d19a5abe9d94253eefa6
|
|
10
10
|
requires_chi: ">= 0.3.0"
|
|
@@ -147,7 +147,8 @@ class Plugin
|
|
|
147
147
|
role = (message[:role] || message["role"]).to_s
|
|
148
148
|
kind = (message[:kind] || message["kind"]).to_s
|
|
149
149
|
return true if kind == "steer" && (message[:content] || message["content"]).to_s.strip == text.strip
|
|
150
|
-
|
|
150
|
+
# A user line merged into the turn (kind input) is part of it: look past it.
|
|
151
|
+
return false if role == "user" && !%w[steer input].include?(kind)
|
|
151
152
|
end
|
|
152
153
|
false
|
|
153
154
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -144,6 +144,10 @@ module Samagotchi
|
|
|
144
144
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
145
145
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
146
146
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
147
|
+
# Seconds a generation must have streamed only thinking before a message
|
|
148
|
+
# for the running turn (the user's, chi send's, a parent agent's) cuts
|
|
149
|
+
# it (Engine#cut_for_steer); 0 = never. No CLI flag: workers get no CLI args.
|
|
150
|
+
Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
|
|
147
151
|
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
148
152
|
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
149
153
|
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -47,6 +47,8 @@ require_relative "tools/memory"
|
|
|
47
47
|
require_relative "muted_memories"
|
|
48
48
|
require_relative "used_memories"
|
|
49
49
|
require_relative "turn_state"
|
|
50
|
+
require_relative "generation_phase"
|
|
51
|
+
require_relative "waiting_steer"
|
|
50
52
|
require_relative "bundle_needs"
|
|
51
53
|
require_relative "model_overlay"
|
|
52
54
|
require_relative "served_model"
|
|
@@ -103,6 +105,10 @@ module Samagotchi
|
|
|
103
105
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
104
106
|
# may steer or ask whether a turn runs while it loads.
|
|
105
107
|
@turn_state = TurnState.new(clock: -> { monotonic_now })
|
|
108
|
+
# What the running generation streams: whether a steer may cut it.
|
|
109
|
+
@generation_phase = GenerationPhase.new(clock: -> { monotonic_now })
|
|
110
|
+
# A message that may cut it but came too early: it cuts later.
|
|
111
|
+
@waiting_steer = WaitingSteer.new
|
|
106
112
|
@scratch = scratch
|
|
107
113
|
@no_interrupt = no_interrupt
|
|
108
114
|
@chat_backend = nil
|
|
@@ -325,6 +331,69 @@ module Samagotchi
|
|
|
325
331
|
@turn_state.steer(text, source: source)
|
|
326
332
|
end
|
|
327
333
|
|
|
334
|
+
# A message for the running turn from +source+ (Steer.source_for_client:
|
|
335
|
+
# nil for the user, "chi_send", "parent_agent") cuts the streaming
|
|
336
|
+
# generation when it has streamed only thinking for steer.cut_after
|
|
337
|
+
# seconds (GenerationPhase): the loops' cut path then starts the step
|
|
338
|
+
# again with the message, no nudge, no retry spent. Too early, the
|
|
339
|
+
# message waits (WaitingSteer) and cuts once the thinking passes it
|
|
340
|
+
# (#recheck_waiting_steer, on each thinking chunk), unless a boundary
|
|
341
|
+
# hands it to the model first. A plugin's message never cuts.
|
|
342
|
+
# Call it after the message is queued, with the #input_epoch read
|
|
343
|
+
# before queueing it. Outside any lock; a cut that lands just after the
|
|
344
|
+
# generation ended is harmless (the loop re-asks).
|
|
345
|
+
# @return [Boolean] whether a generation was cut now
|
|
346
|
+
def cut_for_steer(source, epoch: nil)
|
|
347
|
+
return false unless Steer.cuts?(source)
|
|
348
|
+
|
|
349
|
+
after = Config.get("steer.cut_after").to_i
|
|
350
|
+
return false unless after.positive?
|
|
351
|
+
|
|
352
|
+
ctrl = active_cancel_controller
|
|
353
|
+
return false unless ctrl
|
|
354
|
+
return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
|
|
355
|
+
|
|
356
|
+
Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
|
|
357
|
+
false
|
|
358
|
+
rescue StandardError
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# The drains that took input so far (WaitingSteer#epoch): read it before
|
|
363
|
+
# queueing a message for #cut_for_steer.
|
|
364
|
+
def input_epoch
|
|
365
|
+
@waiting_steer.epoch
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A thinking chunk streamed: a waiting message cuts once the thinking
|
|
369
|
+
# passes steer.cut_after.
|
|
370
|
+
def recheck_waiting_steer
|
|
371
|
+
return unless @waiting_steer.waiting?
|
|
372
|
+
|
|
373
|
+
after = Config.get("steer.cut_after").to_i
|
|
374
|
+
return unless after.positive? && @generation_phase.cuttable?(after)
|
|
375
|
+
|
|
376
|
+
source = @waiting_steer.take
|
|
377
|
+
ctrl = active_cancel_controller
|
|
378
|
+
steer_cut!(ctrl, source, waited: true) if source && ctrl
|
|
379
|
+
rescue StandardError
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
private :recheck_waiting_steer
|
|
383
|
+
|
|
384
|
+
def steer_cut!(ctrl, source, waited: false)
|
|
385
|
+
age = @generation_phase.age
|
|
386
|
+
cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
|
|
387
|
+
if cut
|
|
388
|
+
@waiting_steer.clear!
|
|
389
|
+
fields = { source: source, age: age&.round(1) }
|
|
390
|
+
fields[:waited] = true if waited
|
|
391
|
+
Log.info(:turn, "steer_cut", **fields)
|
|
392
|
+
end
|
|
393
|
+
cut
|
|
394
|
+
end
|
|
395
|
+
private :steer_cut!
|
|
396
|
+
|
|
328
397
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
329
398
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
330
399
|
# any thread; a turn that never begins keeps it for the next one, unless
|
|
@@ -344,10 +413,13 @@ module Samagotchi
|
|
|
344
413
|
# in a --non-interactive run) and the plugin steers. At the after-answer
|
|
345
414
|
# boundary the steers are dropped: the model answered, and a nudge would
|
|
346
415
|
# only restart the turn. The steer part fails on its own, never taking
|
|
347
|
-
# the user's lines with it.
|
|
416
|
+
# the user's lines with it. Items are a String (a user line), a
|
|
417
|
+
# Steer::Line (a worker's input line with its sender) or a steer Hash.
|
|
348
418
|
def turn_drain(pending_input)
|
|
349
419
|
lambda do |at_answer: false|
|
|
350
420
|
lines = pending_input ? Array(pending_input.call) : []
|
|
421
|
+
# The input went to the model: a message that waited to cut is in.
|
|
422
|
+
@waiting_steer.delivered! unless lines.empty?
|
|
351
423
|
lines + take_steers(at_answer)
|
|
352
424
|
end
|
|
353
425
|
end
|
|
@@ -1883,6 +1955,8 @@ module Samagotchi
|
|
|
1883
1955
|
# shared inactivity clock so the idle recap detector (shared with the REPL)
|
|
1884
1956
|
# treats the just-finished turn as activity and re-arms its window.
|
|
1885
1957
|
left = @turn_state.finish!
|
|
1958
|
+
@generation_phase.finished!
|
|
1959
|
+
@waiting_steer.clear!
|
|
1886
1960
|
Client.swap_probe_cancel(probe_cancel_before)
|
|
1887
1961
|
# The next turn's window and served model are asked again: the cache
|
|
1888
1962
|
# (this host's, process-wide) is dropped here, at the turn's end,
|
|
@@ -2331,11 +2405,16 @@ module Samagotchi
|
|
|
2331
2405
|
progress = nil
|
|
2332
2406
|
case event[:type]
|
|
2333
2407
|
when :generation_started
|
|
2408
|
+
@generation_phase.started!
|
|
2334
2409
|
watch&.started(event[:iteration])
|
|
2335
2410
|
when :generation_chunk
|
|
2336
2411
|
# A chunk without the lanes (no loop of ours sends one) counts as text.
|
|
2337
2412
|
text = event.key?(:text) ? event[:text] : event[:content]
|
|
2338
2413
|
progress = { thinking: event[:thinking].to_s, text: text.to_s }
|
|
2414
|
+
@generation_phase.chunk!(**progress, tool_call: event[:tool_call])
|
|
2415
|
+
recheck_waiting_steer unless progress[:thinking].empty?
|
|
2416
|
+
when :generation_retrying then @generation_phase.retrying!
|
|
2417
|
+
when :generation_completed, :generation_cancelled then @generation_phase.finished!
|
|
2339
2418
|
end
|
|
2340
2419
|
# The chat loop asked again without the thinking fields: a notice,
|
|
2341
2420
|
# not an event of its own.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# What the running generation is doing, as the Engine's stream handler
|
|
5
|
+
# sees it: whether a steer may cut it (Engine#cut_for_steer). A
|
|
6
|
+
# generation is cuttable while it has only been thinking: it streamed
|
|
7
|
+
# thinking, still streams it (a thinking delta in the last FRESH_SECONDS),
|
|
8
|
+
# and has streamed no visible text and no tool call. Its age counts from
|
|
9
|
+
# its first thinking delta, so the wait for a first token (a busy or
|
|
10
|
+
# remote server) never counts as thinking. Fed from the stream
|
|
11
|
+
# thread, read from a steer's thread: its own Mutex, never held while
|
|
12
|
+
# calling out.
|
|
13
|
+
class GenerationPhase
|
|
14
|
+
# A thinking delta this recent means the model is still thinking.
|
|
15
|
+
FRESH_SECONDS = 2.0
|
|
16
|
+
|
|
17
|
+
# @param clock [#call] monotonic seconds
|
|
18
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@running = false
|
|
22
|
+
clear_seen
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A generation began.
|
|
26
|
+
def started!
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@running = true
|
|
29
|
+
clear_seen
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# One streamed chunk's lanes. Blank text (a stray newline before Gemma's
|
|
34
|
+
# thought channel) is no text.
|
|
35
|
+
def chunk!(thinking:, text:, tool_call: false)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
unless thinking.to_s.empty?
|
|
38
|
+
@thinking_at = @clock.call
|
|
39
|
+
@thinking_since ||= @thinking_at
|
|
40
|
+
end
|
|
41
|
+
@text = true unless text.to_s.strip.empty?
|
|
42
|
+
@tool_call = true if tool_call
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The request is sent again (a network retry): it streams from the
|
|
47
|
+
# start, so what it had seen (its thinking clock too) is forgotten.
|
|
48
|
+
def retrying!
|
|
49
|
+
@mutex.synchronize { clear_seen }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The generation ended (completed, cut, cancelled, or the turn ended).
|
|
53
|
+
def finished!
|
|
54
|
+
@mutex.synchronize { @running = false }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether a steer may cut the running generation: thinking only, still
|
|
58
|
+
# thinking, and thinking for at least +min_age+ seconds.
|
|
59
|
+
def cuttable?(min_age)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
next false unless @running && @thinking_since && !@text && !@tool_call
|
|
62
|
+
|
|
63
|
+
now = @clock.call
|
|
64
|
+
now - @thinking_at <= FRESH_SECONDS && now - @thinking_since >= min_age
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Seconds since the running generation's first thinking delta; nil
|
|
69
|
+
# with none.
|
|
70
|
+
def age
|
|
71
|
+
@mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def clear_seen
|
|
77
|
+
@thinking_since = nil
|
|
78
|
+
@thinking_at = nil
|
|
79
|
+
@text = false
|
|
80
|
+
@tool_call = false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|