samagotchi 0.30.0 → 0.31.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 +46 -1
- data/docs/configuration.md +4 -0
- data/docs/internals/engine.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/child_reports.rb +214 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +26 -5
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +32 -3
- data/lib/samagotchi/steer.rb +39 -7
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -4
- data/lib/samagotchi/terminal_ui.rb +9 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +7 -7
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +8 -2
- data/lib/samagotchi/web/public/app.js +12 -4
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/notify.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +26 -8
- data/lib/samagotchi/web/public/turn_view.js +23 -4
- data/lib/samagotchi/worker.rb +217 -8
- metadata +3 -1
|
@@ -93,7 +93,13 @@ export function isOwn(clientId, myId) {
|
|
|
93
93
|
|
|
94
94
|
// As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
|
|
95
95
|
// helpers and the TUI's agree per spec/shared/labels_matrix.json.
|
|
96
|
-
const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
|
|
96
|
+
const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
|
|
97
|
+
|
|
98
|
+
// A delegate child's report comes in as client child:<id8> (ChildReports).
|
|
99
|
+
const REPORT_CLIENT = "child:";
|
|
100
|
+
export function isReportClient(clientId) {
|
|
101
|
+
return String(clientId || "").startsWith(REPORT_CLIENT);
|
|
102
|
+
}
|
|
97
103
|
|
|
98
104
|
// The label on another client's prompt, by its client_id prefix.
|
|
99
105
|
export function clientLabel(clientId) {
|
|
@@ -119,9 +125,10 @@ export function hookNoticeLabel(hook) {
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
// The row a step gets when the loop asks again after an empty answer, or
|
|
122
|
-
// after a plugin cut the generation (stopped_by: the bundle)
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
// after a plugin cut the generation (stopped_by: the bundle), or after a
|
|
129
|
+
// corrupt native generation (malformed).
|
|
130
|
+
export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy, malformed } = {}) {
|
|
131
|
+
const what = stoppedBy ? `cut by ${stoppedBy}` : malformed ? "malformed answer" : "empty answer";
|
|
125
132
|
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -381,9 +388,14 @@ export function composerAfterAck({ sent = "", current = "", refill = "" } = {})
|
|
|
381
388
|
// tag — this tab's own local echo gets its enqueued_id
|
|
382
389
|
// start — the bubble's turn started (drops the "queued" badge)
|
|
383
390
|
// steer — the bubble was merged into the running turn
|
|
391
|
+
// report — the merge brought a delegate child's report (origin
|
|
392
|
+
// child:<id8>, no bubble anywhere): its text gets a labelled one
|
|
384
393
|
// `known(enqueuedId)` says whether a bubble carries that id already;
|
|
385
|
-
// `unmatchedMerge` whether the last input_merged had an origin with no
|
|
386
|
-
|
|
394
|
+
// `unmatchedMerge` whether the last input_merged had an origin with no
|
|
395
|
+
// bubble ("report": a delegate report's); `wakeStart` whether this is the
|
|
396
|
+
// first merge of a turn chi ran for reports (its bubble starts the turn,
|
|
397
|
+
// no "steered" badge, as a reload shows it).
|
|
398
|
+
export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStart = false }) {
|
|
387
399
|
if (!event) return [];
|
|
388
400
|
switch (event.type) {
|
|
389
401
|
case "turn_enqueued": {
|
|
@@ -403,12 +415,18 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
|
|
|
403
415
|
}
|
|
404
416
|
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
|
|
405
417
|
}
|
|
406
|
-
case "input_merged":
|
|
407
|
-
|
|
418
|
+
case "input_merged": {
|
|
419
|
+
const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
|
|
408
420
|
known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
|
|
409
421
|
);
|
|
422
|
+
if ((event.origins || []).some((o) => o && !o.enqueued_id && isReportClient(o.client_id))) ops.push({ op: "report" });
|
|
423
|
+
return ops;
|
|
424
|
+
}
|
|
410
425
|
case "pending_input_merged":
|
|
411
426
|
if (!unmatchedMerge || !event.content) return [];
|
|
427
|
+
if (unmatchedMerge === "report") {
|
|
428
|
+
return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
|
|
429
|
+
}
|
|
412
430
|
return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
|
|
413
431
|
default:
|
|
414
432
|
return [];
|
|
@@ -14,7 +14,7 @@ import { leavesBlock } from "./card.js";
|
|
|
14
14
|
import { copySourceAttr } from "./copy.js";
|
|
15
15
|
import { commandBlockHtml, rowHover } from "./command_view.js";
|
|
16
16
|
import { activityDiffHtml } from "./diff_view.js";
|
|
17
|
-
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
|
+
import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
|
|
18
18
|
import { thumbsHtml } from "./images.js";
|
|
19
19
|
import { shouldFollowScroll } from "./scroll.js";
|
|
20
20
|
import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
|
|
@@ -805,13 +805,28 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
805
805
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
806
806
|
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
807
807
|
};
|
|
808
|
+
// The live bubble's origin label (app.js makeUserBubble), for a line
|
|
809
|
+
// chi brought in: a delegate report.
|
|
810
|
+
const originLabelHtml = (m) => {
|
|
811
|
+
const label = sourceLabel(m?.source);
|
|
812
|
+
return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
|
|
813
|
+
};
|
|
814
|
+
// The first text after a delegate report merged mid-turn, unless it is
|
|
815
|
+
// the turn's answer (shown anyway).
|
|
816
|
+
const reportReplyHtml = (i, group) => {
|
|
817
|
+
if (items[i]?.source !== "delegate_report") return "";
|
|
818
|
+
const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
|
|
819
|
+
if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
|
|
820
|
+
const { body, renderedMarkdown } = messageBodyHtml(items[j]);
|
|
821
|
+
return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
|
|
822
|
+
};
|
|
808
823
|
const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
|
|
809
824
|
return turnGroups(items, timing).map((group) => {
|
|
810
825
|
if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
|
|
811
826
|
if (group.kind === "steer") return steerHtml(group.i, true);
|
|
812
827
|
if (group.kind === "answer") return answerHtml(items[group.answer], "");
|
|
813
828
|
const prompt = items[group.user];
|
|
814
|
-
const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}
|
|
829
|
+
const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
|
|
815
830
|
const [only] = group.steps;
|
|
816
831
|
const ended = group.answer !== null || group.emptyAnswer != null;
|
|
817
832
|
const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
|
|
@@ -843,9 +858,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
843
858
|
const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
|
|
844
859
|
parts.push(loose.map((i) => steerHtml(i, true)).join(""));
|
|
845
860
|
// The user's lines merged into the turn: the live "steered" bubbles,
|
|
846
|
-
// after its steps and before its answer.
|
|
861
|
+
// after its steps and before its answer. A delegate report's is
|
|
862
|
+
// followed by the text that answered it when that isn't the answer: a
|
|
863
|
+
// small model tells its user about a report before its next tool call,
|
|
864
|
+
// and that step's text would stay folded in the block.
|
|
847
865
|
parts.push((group.merged || []).map((i) =>
|
|
848
|
-
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}
|
|
866
|
+
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}>${originLabelHtml(items[i])}<div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>` +
|
|
867
|
+
reportReplyHtml(i, group)).join(""));
|
|
849
868
|
// A turn that ended with no answer: chi's muted notice where the answer
|
|
850
869
|
// would be, before the timing, as live.
|
|
851
870
|
if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative "continue_offer"
|
|
|
17
17
|
require_relative "iteration_limit"
|
|
18
18
|
require_relative "session_commands"
|
|
19
19
|
require_relative "model_profile"
|
|
20
|
+
require_relative "child_reports"
|
|
20
21
|
require_relative "tools/task_runtime"
|
|
21
22
|
|
|
22
23
|
module Samagotchi
|
|
@@ -45,6 +46,11 @@ module Samagotchi
|
|
|
45
46
|
# turn) is refused as busy.
|
|
46
47
|
class Worker
|
|
47
48
|
FALLBACK_TICK_SECONDS = 5
|
|
49
|
+
# A worker just started runs no wake turn for delegate reports this
|
|
50
|
+
# long: a worker spawned for a message (chi send to a stopped parent)
|
|
51
|
+
# gets it over the Bridge a moment after it starts, and that message's
|
|
52
|
+
# turn goes first (the reports join it).
|
|
53
|
+
WAKE_START_GRACE = 2.0
|
|
48
54
|
# How much of a command's output goes into its :command_ran.
|
|
49
55
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
50
56
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
@@ -89,6 +95,25 @@ module Samagotchi
|
|
|
89
95
|
@discard = false
|
|
90
96
|
# The exit asked for is a restart (POST /exit restart: true).
|
|
91
97
|
@exit_restart = false
|
|
98
|
+
# The lines the running turn's drain merged ([prompt, origin]), for a
|
|
99
|
+
# failed prompt turn to hand back; reset as each turn begins.
|
|
100
|
+
@merged_this_turn = []
|
|
101
|
+
# A delegate child's side (ChildRing): whether the running turn is
|
|
102
|
+
# its parent's (it rings when it ends), whether that turn rang a
|
|
103
|
+
# question, and whether the next turn rings whatever its origin (it
|
|
104
|
+
# follows a question the parent was rung about: D10).
|
|
105
|
+
@reporting_turn = false
|
|
106
|
+
@asked_parent = false
|
|
107
|
+
@owes_parent = false
|
|
108
|
+
@initial_turn = false
|
|
109
|
+
# A parent's side: turns run for delegate reports since the last human
|
|
110
|
+
# input (session.max_wakes), whether a failed one paused them until
|
|
111
|
+
# then, whether the budget notice went out, and the reports a wake
|
|
112
|
+
# turn's first boundary hands over.
|
|
113
|
+
@wakes_in_a_row = 0
|
|
114
|
+
@wakes_paused = false
|
|
115
|
+
@budget_noticed = false
|
|
116
|
+
@wake_reports = nil
|
|
92
117
|
end
|
|
93
118
|
|
|
94
119
|
# Whether the session was empty as the worker left it, so the caller
|
|
@@ -96,6 +121,12 @@ module Samagotchi
|
|
|
96
121
|
# checks again then).
|
|
97
122
|
def discard? = @discard
|
|
98
123
|
|
|
124
|
+
# Whether delegate reports wait that an idle parent runs a turn for
|
|
125
|
+
# (#wake_state): SessionManager.run_session_loop starts a new worker
|
|
126
|
+
# for them after this one leaves. Reports the budget or a failure held
|
|
127
|
+
# back wait for the next human input instead.
|
|
128
|
+
def wake_due? = wake_state == :due
|
|
129
|
+
|
|
99
130
|
# What a new session starts on (and /model resets to); nil before #run.
|
|
100
131
|
attr_reader :default_model
|
|
101
132
|
|
|
@@ -105,6 +136,7 @@ module Samagotchi
|
|
|
105
136
|
# (chi stop), :crashed when the loop raised (the session is marked
|
|
106
137
|
# errored); SessionManager.run_session_loop turns it into the exit
|
|
107
138
|
def run
|
|
139
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
140
|
@session = Session.load(@session_id, state_dir: @state_dir)
|
|
109
141
|
drop_dead_question
|
|
110
142
|
@engine = build_engine
|
|
@@ -114,7 +146,7 @@ module Samagotchi
|
|
|
114
146
|
@engine.session = @session
|
|
115
147
|
# Input already waiting in the inbox starts the next turn at once: no
|
|
116
148
|
# turn-end warm-up then.
|
|
117
|
-
@engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
|
|
149
|
+
@engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? || wake_due? }
|
|
118
150
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
119
151
|
# Its question's answer is queued as the matching /continue (the
|
|
120
152
|
# Bridge exists by the time anyone answers).
|
|
@@ -131,7 +163,11 @@ module Samagotchi
|
|
|
131
163
|
@turn_end_seq = 0
|
|
132
164
|
@engine.subscribe(observer: lambda { |event|
|
|
133
165
|
@turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
|
|
166
|
+
@turn_id = event[:turn_id] if event[:type] == :turn_started
|
|
167
|
+
ring_question(event) if event[:type] == :question_requested
|
|
134
168
|
})
|
|
169
|
+
# This session's own delegate children's news (rings in children/).
|
|
170
|
+
@child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
|
|
135
171
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
136
172
|
# on the session's model.
|
|
137
173
|
@default_model = ModelProfile.required_model_name(nil)
|
|
@@ -190,17 +226,18 @@ module Samagotchi
|
|
|
190
226
|
absorb_notes
|
|
191
227
|
|
|
192
228
|
if (prompt = take_initial_prompt)
|
|
193
|
-
|
|
229
|
+
run_initial_prompt(prompt) unless initial_command(prompt)
|
|
194
230
|
next
|
|
195
231
|
end
|
|
196
232
|
|
|
197
233
|
input_files = SessionInbox.find_new_input_files(@session_dir)
|
|
198
234
|
if input_files.empty?
|
|
235
|
+
next if run_wake_turn
|
|
199
236
|
next if run_due_reminders
|
|
200
237
|
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
201
238
|
return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
|
|
202
239
|
|
|
203
|
-
@waker.wait(
|
|
240
|
+
@waker.wait(idle_wait)
|
|
204
241
|
next
|
|
205
242
|
end
|
|
206
243
|
|
|
@@ -216,6 +253,8 @@ module Samagotchi
|
|
|
216
253
|
# Its stderr is /dev/null: the log is the only trace of why.
|
|
217
254
|
Log.exception(:worker, "crashed", e)
|
|
218
255
|
Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
|
|
256
|
+
# A delegate's parent hears of it (best effort).
|
|
257
|
+
ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
|
|
219
258
|
:crashed
|
|
220
259
|
ensure
|
|
221
260
|
# The anytime commands finish and the plugins' services stop (a
|
|
@@ -333,6 +372,15 @@ module Samagotchi
|
|
|
333
372
|
claimed.each { |file| FileUtils.rm_f(file) }
|
|
334
373
|
end
|
|
335
374
|
|
|
375
|
+
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
376
|
+
# its parent's although no client sent it.
|
|
377
|
+
def run_initial_prompt(prompt)
|
|
378
|
+
@initial_turn = true
|
|
379
|
+
run_prompt(prompt, nil)
|
|
380
|
+
ensure
|
|
381
|
+
@initial_turn = false
|
|
382
|
+
end
|
|
383
|
+
|
|
336
384
|
def run_input_file(input_file)
|
|
337
385
|
claimed_file = SessionInbox.claim_input_file(input_file)
|
|
338
386
|
return unless claimed_file
|
|
@@ -355,7 +403,6 @@ module Samagotchi
|
|
|
355
403
|
user_input(origin&.dig(:client_id))
|
|
356
404
|
@continue_offer.drop(origin)
|
|
357
405
|
@turn_flow.before_prompt_turn
|
|
358
|
-
@merged_this_turn = []
|
|
359
406
|
run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
|
|
360
407
|
images: images) do |result, error|
|
|
361
408
|
if error
|
|
@@ -387,6 +434,85 @@ module Samagotchi
|
|
|
387
434
|
true
|
|
388
435
|
end
|
|
389
436
|
|
|
437
|
+
# :due when an idle parent should run a turn for its delegate
|
|
438
|
+
# children's reports now (session.delegate_reports: wake): rings wait,
|
|
439
|
+
# no continue offer does (they merge into its turn), wakes aren't paused
|
|
440
|
+
# by a failed one, and the wake budget isn't spent; :budget when only
|
|
441
|
+
# the budget stops it; nil otherwise.
|
|
442
|
+
def wake_state
|
|
443
|
+
return nil unless @child_reports && ChildRing.mode == "wake"
|
|
444
|
+
return nil if @wakes_paused || @turn_flow&.awaiting_continue?
|
|
445
|
+
return nil unless @child_reports.waiting?
|
|
446
|
+
|
|
447
|
+
@wakes_in_a_row < max_wakes ? :due : :budget
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Run a turn nobody typed for the reports the rings bring (a wake turn):
|
|
451
|
+
# a continue turn (no prompt bubble) whose first boundary merges them,
|
|
452
|
+
# with origin child:<id8>. A failure goes back to the checkpoint, keeps
|
|
453
|
+
# the rings and pauses wakes until a human's input (a provider outage
|
|
454
|
+
# doesn't loop).
|
|
455
|
+
# @return [Boolean] whether a wake turn ran
|
|
456
|
+
def run_wake_turn
|
|
457
|
+
return false if wake_grace_left.positive?
|
|
458
|
+
|
|
459
|
+
state = wake_state
|
|
460
|
+
budget_notice if state == :budget
|
|
461
|
+
return false unless state == :due
|
|
462
|
+
|
|
463
|
+
reports, = take_child_reports
|
|
464
|
+
# The turn must not generate with nothing to say: the drain gets this
|
|
465
|
+
# exact list at its first boundary.
|
|
466
|
+
return false if reports.empty?
|
|
467
|
+
|
|
468
|
+
@wake_reports = reports
|
|
469
|
+
@wakes_in_a_row += 1
|
|
470
|
+
Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
|
|
471
|
+
@turn_flow.before_prompt_turn
|
|
472
|
+
run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
|
|
473
|
+
if error
|
|
474
|
+
note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
|
|
475
|
+
@turn_flow.prompt_turn_failed(note: note)
|
|
476
|
+
@wakes_paused = true
|
|
477
|
+
else
|
|
478
|
+
@continue_offer.after_turn(result)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
true
|
|
482
|
+
ensure
|
|
483
|
+
@wake_reports = nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def wake_grace_left
|
|
487
|
+
WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# The idle loop's sleep: the fallback tick, or less while reports wait
|
|
491
|
+
# out the start grace.
|
|
492
|
+
def idle_wait
|
|
493
|
+
left = wake_grace_left
|
|
494
|
+
left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def max_wakes
|
|
498
|
+
value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
|
|
499
|
+
value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
|
|
500
|
+
rescue StandardError
|
|
501
|
+
ChildRing::MAX_WAKES_DEFAULT
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# The wake budget is spent: the parent's UIs hear once that reports
|
|
505
|
+
# wait for the next message.
|
|
506
|
+
def budget_notice
|
|
507
|
+
return if @budget_noticed
|
|
508
|
+
|
|
509
|
+
@budget_noticed = true
|
|
510
|
+
count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
|
|
511
|
+
@engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
|
|
512
|
+
text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
|
|
513
|
+
"your next message (#{max_wakes} turn#{"s" if max_wakes != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
|
|
514
|
+
end
|
|
515
|
+
|
|
390
516
|
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
391
517
|
|
|
392
518
|
# @return [Boolean] whether any command ran
|
|
@@ -496,6 +622,10 @@ module Samagotchi
|
|
|
496
622
|
# @yieldparam result [Object, nil] Engine#run_turn's, nil on a failure
|
|
497
623
|
# @yieldparam error [StandardError, nil]
|
|
498
624
|
def run_engine_turn(prompt, **turn_args)
|
|
625
|
+
# Every kind of turn merges steering (a reminder turn too, which may be
|
|
626
|
+
# a fresh worker's first), so the list is this turn's from the start.
|
|
627
|
+
@merged_this_turn = []
|
|
628
|
+
@reporting_turn = reports_to_parent?(turn_args[:origin])
|
|
499
629
|
begin
|
|
500
630
|
@session.status = Session::STATUS_RUNNING
|
|
501
631
|
@session.save(state_dir: @state_dir)
|
|
@@ -519,6 +649,51 @@ module Samagotchi
|
|
|
519
649
|
SessionInbox.write_output(@session_dir, response)
|
|
520
650
|
end
|
|
521
651
|
save_or_log(:turn) { save_session }
|
|
652
|
+
settle_child_reports(error)
|
|
653
|
+
ring_parent_after_turn
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# The delegate reports this turn took: a kept turn moves the cursors on
|
|
657
|
+
# and deletes their rings; a failed one (rolled back) leaves the rings
|
|
658
|
+
# for the next turn.
|
|
659
|
+
def settle_child_reports(error)
|
|
660
|
+
error ? @child_reports.release : @child_reports.commit
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# A delegate child's turn that was its parent's (#reports_to_parent?)
|
|
664
|
+
# rings the parent, after the save: the parent reads a settled child.
|
|
665
|
+
# A question it rang about that still waits makes the next turn ring
|
|
666
|
+
# too, whoever starts it (the user answering Continue on the web).
|
|
667
|
+
def ring_parent_after_turn
|
|
668
|
+
ChildRing.ring(@session, why: "turn_end", state_dir: @state_dir) if @reporting_turn
|
|
669
|
+
@owes_parent = @asked_parent && !@session.pending_question.nil?
|
|
670
|
+
@asked_parent = false
|
|
671
|
+
@reporting_turn = false
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Whether the turn starting now is a delegate child's parent's: its
|
|
675
|
+
# task (the initial prompt), a follow-up, a continue the parent
|
|
676
|
+
# answered, a reminder, anything but a human typing into the child
|
|
677
|
+
# (ArchiveStore.user_input?), and any turn after a question the parent
|
|
678
|
+
# was rung about.
|
|
679
|
+
def reports_to_parent?(origin)
|
|
680
|
+
return false unless @session.delegate?
|
|
681
|
+
|
|
682
|
+
@initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# The Engine published a question (after the question desk saved it).
|
|
686
|
+
# In a turn that reports to the parent, the model's own question and
|
|
687
|
+
# the step-limit continue ring it; approvals and hooks' questions are
|
|
688
|
+
# the user's, on this session's card.
|
|
689
|
+
def ring_question(event)
|
|
690
|
+
return unless @reporting_turn
|
|
691
|
+
|
|
692
|
+
kind = event.dig(:pending_question, :kind).to_s
|
|
693
|
+
return unless ["", ContinueOffer::KIND].include?(kind)
|
|
694
|
+
|
|
695
|
+
ChildRing.ring(@session, why: "question", state_dir: @state_dir)
|
|
696
|
+
@asked_parent = true
|
|
522
697
|
end
|
|
523
698
|
|
|
524
699
|
# A save between turns (the turn's own, a command's, the notes', the
|
|
@@ -588,14 +763,42 @@ module Samagotchi
|
|
|
588
763
|
end
|
|
589
764
|
end
|
|
590
765
|
merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
|
|
591
|
-
|
|
592
|
-
|
|
766
|
+
# Delegate children's reports: never handed back on a failure
|
|
767
|
+
# (their rings stay for the next turn), so not in @merged_this_turn.
|
|
768
|
+
reports, mark = take_child_reports
|
|
769
|
+
unless merged.empty? && reports.empty?
|
|
770
|
+
@engine.announce(type: :input_merged, count: merged.size + reports.size,
|
|
771
|
+
origins: merged.filter_map(&:last) + reports.map(&:origin))
|
|
593
772
|
@merged_this_turn.concat(merged)
|
|
594
773
|
end
|
|
595
774
|
# Each line keeps its sender (Steer.source_for_client): a chi send or
|
|
596
775
|
# a delegating parent's line is saved and shown to the model as theirs.
|
|
597
|
-
merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) }
|
|
776
|
+
lines = merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) } +
|
|
777
|
+
reports.map(&:line)
|
|
778
|
+
# A wake turn starts with its first line, whoever sent it.
|
|
779
|
+
lines[0] = lines[0].with(mark: mark) if mark && lines.any?
|
|
780
|
+
lines
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# The reports for rings that came in since the turn last looked. A
|
|
785
|
+
# failure to read them must not take the turn's steering with it.
|
|
786
|
+
# A wake turn's first call gets the reports it was started for (their
|
|
787
|
+
# message is the turn's start: turn_start, turn_id, so a reload shows
|
|
788
|
+
# the turn as its own).
|
|
789
|
+
# @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
|
|
790
|
+
# and the mark of their message
|
|
791
|
+
def take_child_reports
|
|
792
|
+
if @wake_reports
|
|
793
|
+
reports = @wake_reports
|
|
794
|
+
@wake_reports = nil
|
|
795
|
+
return [reports, { turn_start: true, turn_id: @turn_id }.compact]
|
|
598
796
|
end
|
|
797
|
+
|
|
798
|
+
[@child_reports.take, nil]
|
|
799
|
+
rescue StandardError => e
|
|
800
|
+
Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
|
|
801
|
+
[[], nil]
|
|
599
802
|
end
|
|
600
803
|
|
|
601
804
|
# Check again with the event log held, which the Bridge holds while it
|
|
@@ -696,8 +899,14 @@ module Samagotchi
|
|
|
696
899
|
|
|
697
900
|
# A human's input (ArchiveStore.user_input?) un-archives the session;
|
|
698
901
|
# a delegate's, a plugin's or a reminder's doesn't.
|
|
902
|
+
# It also resets the delegate-report wakes (budget and pause).
|
|
699
903
|
def user_input(client_id)
|
|
700
|
-
|
|
904
|
+
return unless ArchiveStore.user_input?(client_id)
|
|
905
|
+
|
|
906
|
+
ArchiveStore.user_input(@session_id, state_dir: @state_dir)
|
|
907
|
+
@wakes_in_a_row = 0
|
|
908
|
+
@wakes_paused = false
|
|
909
|
+
@budget_noticed = false
|
|
701
910
|
end
|
|
702
911
|
|
|
703
912
|
def stopped_on_disk?
|
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.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -193,6 +193,7 @@ files:
|
|
|
193
193
|
- lib/samagotchi/bundles/system/memory_guide.md
|
|
194
194
|
- lib/samagotchi/bundles/system/self_map.md
|
|
195
195
|
- lib/samagotchi/cancellation_controller.rb
|
|
196
|
+
- lib/samagotchi/child_reports.rb
|
|
196
197
|
- lib/samagotchi/cli/command.rb
|
|
197
198
|
- lib/samagotchi/cli/exit.rb
|
|
198
199
|
- lib/samagotchi/cli/flags.rb
|
|
@@ -394,6 +395,7 @@ files:
|
|
|
394
395
|
- lib/samagotchi/tools/builtins.rb
|
|
395
396
|
- lib/samagotchi/tools/cancel_reminder.rb
|
|
396
397
|
- lib/samagotchi/tools/delegate.rb
|
|
398
|
+
- lib/samagotchi/tools/delegate_cursor.rb
|
|
397
399
|
- lib/samagotchi/tools/delegate_relay.rb
|
|
398
400
|
- lib/samagotchi/tools/delegate_result.rb
|
|
399
401
|
- lib/samagotchi/tools/delegate_wait.rb
|