samagotchi 0.90.0 → 0.92.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 +79 -1
- data/README.md +5 -2
- data/bin/chi +4 -4
- data/docs/architecture.md +17 -13
- data/docs/bundle-authoring.md +6 -11
- data/docs/cli.md +7 -4
- data/docs/configuration.md +8 -4
- data/docs/context.md +5 -3
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +27 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +21 -8
- data/docs/internals/prompt-caching.md +4 -0
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/lib/samagotchi/bridge.rb +11 -7
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +26 -15
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +5 -0
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/client_id.rb +3 -0
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_note.rb +13 -6
- data/lib/samagotchi/engine.rb +31 -15
- data/lib/samagotchi/hooks/registry.rb +2 -0
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/last_turn.rb +3 -2
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command.rb +3 -3
- data/lib/samagotchi/session_manager.rb +12 -20
- data/lib/samagotchi/task_finish_note.rb +90 -0
- data/lib/samagotchi/task_watch.rb +228 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +19 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/testing/bundle.rb +32 -19
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_declarations.rb +16 -0
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/builtins.rb +5 -1
- data/lib/samagotchi/tools/task_record.rb +9 -0
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/tools/task_wait.rb +10 -3
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +24 -14
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/notify.js +4 -4
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/turn_events.js +13 -1
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +104 -42
- data/lib/samagotchi/worker_idle_exit.rb +26 -3
- data/lib/samagotchi/worker_inbound.rb +18 -4
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +11 -4
- metadata +8 -1
|
@@ -35,10 +35,10 @@ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
|
35
35
|
import { renderInfoBar } from "./info_bar.js";
|
|
36
36
|
import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
|
|
37
37
|
import { createShownTexts } from "./shown_texts.js";
|
|
38
|
-
import { MODEL_KEY } from "./model_pick.js";
|
|
38
|
+
import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
|
|
39
39
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
40
40
|
import { createModelPicker } from "./model_picker.js";
|
|
41
|
-
import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
+
import { admitImages, chipName, chipsAfterSend, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
42
42
|
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
43
43
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
44
44
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
@@ -992,7 +992,10 @@ async function readTurnEnd() {
|
|
|
992
992
|
lists.render();
|
|
993
993
|
lists.paintActive();
|
|
994
994
|
}
|
|
995
|
-
} catch(_){
|
|
995
|
+
} catch (_) {
|
|
996
|
+
// A failed read (the server or worker gone a moment) leaves the answer
|
|
997
|
+
// as the stream drew it, plain text: the session's next load renders it.
|
|
998
|
+
}
|
|
996
999
|
}
|
|
997
1000
|
|
|
998
1001
|
function seedSeenFromDom() {
|
|
@@ -1452,8 +1455,9 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
|
1452
1455
|
if (restored?.refill && sentTo === selected) openForVisit();
|
|
1453
1456
|
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
1454
1457
|
fitPrompt();
|
|
1455
|
-
|
|
1456
|
-
|
|
1458
|
+
// Only the sent chips go: images added since stay (they go up at their own send).
|
|
1459
|
+
const kept = chipsAfterSend(chips, sending);
|
|
1460
|
+
setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
|
|
1457
1461
|
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
1458
1462
|
// worker that wasn't live (no turn_enqueued then).
|
|
1459
1463
|
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
@@ -1548,7 +1552,7 @@ async function handleCancel() {
|
|
|
1548
1552
|
$("#newBtn").addEventListener("click", () => {
|
|
1549
1553
|
if (allViewOpen()) leaveAllView("");
|
|
1550
1554
|
clearSelection();
|
|
1551
|
-
loadModels();
|
|
1555
|
+
loadModels({ asked: true });
|
|
1552
1556
|
});
|
|
1553
1557
|
|
|
1554
1558
|
// ── The start page's model picker ──────────────────────────────────────────
|
|
@@ -1641,11 +1645,19 @@ function restoreStartPick() {
|
|
|
1641
1645
|
renderNewChatLlmContext();
|
|
1642
1646
|
renderNewChatThinking();
|
|
1643
1647
|
}
|
|
1644
|
-
|
|
1648
|
+
// A reload cuts the fetch: its error is no news (modelsFailedText).
|
|
1649
|
+
let pageLeaving = false;
|
|
1650
|
+
addEventListener("beforeunload", () => { pageLeaving = true; });
|
|
1651
|
+
addEventListener("pagehide", () => { pageLeaving = true; });
|
|
1652
|
+
addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
|
|
1653
|
+
// +asked+: + new session's load, for the start page it opens.
|
|
1654
|
+
async function loadModels({ asked = false } = {}) {
|
|
1645
1655
|
let payload;
|
|
1646
1656
|
try {
|
|
1647
1657
|
payload = await listModels();
|
|
1648
|
-
} catch (
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
const text = modelsFailedText(e, { startPage: !selected, asked, leaving: pageLeaving });
|
|
1660
|
+
if (text) showToast(text);
|
|
1649
1661
|
return;
|
|
1650
1662
|
}
|
|
1651
1663
|
const stored = readStored(MODEL_KEY);
|
|
@@ -1678,16 +1690,13 @@ function renderChips() {
|
|
|
1678
1690
|
drawFoldControls();
|
|
1679
1691
|
}
|
|
1680
1692
|
|
|
1693
|
+
// A chip that goes frees its preview; one kept in +list+ keeps it.
|
|
1681
1694
|
function setChips(list) {
|
|
1682
|
-
for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
|
|
1695
|
+
for (const c of chips) if (c.file && c.src && !list.includes(c)) URL.revokeObjectURL(c.src);
|
|
1683
1696
|
chips = list;
|
|
1684
1697
|
renderChips();
|
|
1685
1698
|
}
|
|
1686
1699
|
|
|
1687
|
-
function clearChips() {
|
|
1688
|
-
setChips([]);
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
1700
|
// A paste's, a drop's or the image button's files: the images the message
|
|
1692
1701
|
// may still take become chips (images.js admitImages); a toast says why the
|
|
1693
1702
|
// rest stayed out. Whether any went in.
|
|
@@ -1806,7 +1815,8 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
1806
1815
|
const unqueue = createUnqueue({
|
|
1807
1816
|
sessionId: () => selected,
|
|
1808
1817
|
clientId,
|
|
1809
|
-
toast: (text) => showToast(text),
|
|
1818
|
+
toast: (text, action, options) => showToast(text, action, options),
|
|
1819
|
+
copy: (text) => copyText(text),
|
|
1810
1820
|
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1811
1821
|
putBack: (text) => {
|
|
1812
1822
|
if (promptEl.value.trim() || chips.length) return false;
|
|
@@ -150,7 +150,10 @@ export function createCardsPlace({
|
|
|
150
150
|
const el = card.type === "card" && cardElement(card.id);
|
|
151
151
|
if (el) refreshCardBody(el, card);
|
|
152
152
|
}
|
|
153
|
-
} catch (_) {
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// The live text stays (a failed re-read is only a lost render);
|
|
155
|
+
// the next card or load renders it.
|
|
156
|
+
}
|
|
154
157
|
}, 150);
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -94,6 +94,12 @@ export function chipsTemplate(chips) {
|
|
|
94
94
|
html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// The chips left once a send is acked: those added while it was in flight
|
|
98
|
+
// (an upload can take seconds) stay for the next message.
|
|
99
|
+
export function chipsAfterSend(chips, sent) {
|
|
100
|
+
return (chips || []).filter((c) => !sent.includes(c));
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
// The {file, name} refs a turn names.
|
|
98
104
|
export function turnRefs(chips) {
|
|
99
105
|
return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// The start page's model picker: pure helpers over GET /api/models' payload.
|
|
2
|
+
import { cantText } from "./format.js";
|
|
2
3
|
|
|
3
4
|
export const MODEL_KEY = "chi_model";
|
|
4
5
|
|
|
6
|
+
// A failed GET /api/models' toast: the picker stays hidden, so it says why
|
|
7
|
+
// and what a new session gets (+ new session asks again). "" (no toast) in
|
|
8
|
+
// a session, where no picker shows (+asked+: + new session's own load),
|
|
9
|
+
// and for a fetch aborted or cut by the page unloading.
|
|
10
|
+
export function modelsFailedText(error, { startPage = true, asked = false, leaving = false } = {}) {
|
|
11
|
+
if (leaving || error?.name === "AbortError" || !(startPage || asked)) return "";
|
|
12
|
+
return `${cantText("list the models", error)}. A new session starts on the default model.`;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// The name to preselect from the rows (modelRows): the remembered one when
|
|
6
16
|
// it is still offered (any case), else the server's default, both even
|
|
7
17
|
// when their host is down; else the first row whose host answered, else
|
|
@@ -15,11 +15,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// A finished turn shorter than this was watched: no "done". Not a turn
|
|
18
|
-
// chi ran for a delegate child's report (last_turn origin delegate_report)
|
|
19
|
-
//
|
|
20
|
-
// watched it.
|
|
18
|
+
// chi ran for a delegate child's report (last_turn origin delegate_report),
|
|
19
|
+
// an attached context change (context) or a finished background task
|
|
20
|
+
// (task): nobody started it, so nobody watched it.
|
|
21
21
|
const DONE_MIN_SECONDS = 10;
|
|
22
|
-
const UNWATCHED_ORIGINS = ["delegate_report", "context"];
|
|
22
|
+
const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
|
|
23
23
|
|
|
24
24
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
25
25
|
// and asks whether to go on) has its own reason: "out of steps".
|
|
@@ -36,19 +36,26 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
36
36
|
entry.el);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function item(text, action, sticky) {
|
|
40
|
+
const entry = { el: doc.createElement("div"), timer: null, sticky, action, key: null };
|
|
41
|
+
entry.el.className = "toast-item";
|
|
42
|
+
draw(entry, text, action, sticky);
|
|
43
|
+
entry.key = entry.el.querySelector(".toast-message").textContent;
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
function show(text, action = null, { sticky = false } = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
// Words are their own key; a template's key is its drawn text, so only
|
|
49
|
+
// a template is drawn before the look-up.
|
|
50
|
+
const candidate = typeof text === "string" ? null : item(text, action, sticky);
|
|
51
|
+
const key = candidate ? candidate.key : text;
|
|
44
52
|
|
|
45
|
-
const duplicate = entries.find((entry) => entry.key ===
|
|
53
|
+
const duplicate = entries.find((entry) => entry.key === key);
|
|
46
54
|
if (duplicate) {
|
|
47
55
|
if (duplicate.timer !== null) clearTimeout(duplicate.timer);
|
|
48
56
|
duplicate.timer = null;
|
|
49
57
|
duplicate.sticky = sticky || duplicate.sticky;
|
|
50
58
|
duplicate.action = action || duplicate.action;
|
|
51
|
-
duplicate.key = candidate.key;
|
|
52
59
|
draw(duplicate, text, duplicate.action, duplicate.sticky);
|
|
53
60
|
entries.push(...entries.splice(entries.indexOf(duplicate), 1));
|
|
54
61
|
el.appendChild(duplicate.el);
|
|
@@ -60,9 +67,10 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
|
|
|
60
67
|
if (entries.length === MAX_TOASTS) {
|
|
61
68
|
remove(entries.find((entry) => !entry.sticky) || entries[0], false);
|
|
62
69
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
const entry = candidate || item(text, action, sticky);
|
|
71
|
+
entries.push(entry);
|
|
72
|
+
el.appendChild(entry.el);
|
|
73
|
+
if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
|
|
66
74
|
sync();
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -107,13 +107,25 @@ export function isReportClient(clientId) {
|
|
|
107
107
|
|
|
108
108
|
// A turn chi ran because an attached context source changed: context:<name>.
|
|
109
109
|
const CONTEXT_CLIENT = "context:";
|
|
110
|
+
// A turn chi ran because a background task the session started finished
|
|
111
|
+
// unseen: task:<task id> (the Worker's finish wake).
|
|
112
|
+
const TASK_CLIENT = "task:";
|
|
113
|
+
|
|
114
|
+
// A task id as people read it: the random part a task_create id ends with
|
|
115
|
+
// (it starts with the time), else its first 8 chars (TaskRecord.short_id).
|
|
116
|
+
function shortTaskId(id) {
|
|
117
|
+
const m = /-([0-9a-f]{8})$/i.exec(id);
|
|
118
|
+
return m ? m[1] : id.slice(0, 8);
|
|
119
|
+
}
|
|
110
120
|
|
|
111
121
|
// The label on another client's prompt, by its client_id prefix; a context
|
|
112
|
-
// change's turn as "context <name> changed"
|
|
122
|
+
// change's turn as "context <name> changed", a finished task's as
|
|
123
|
+
// "task <id8> finished".
|
|
113
124
|
export function clientLabel(clientId) {
|
|
114
125
|
if (!clientId) return null;
|
|
115
126
|
const id = String(clientId);
|
|
116
127
|
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
128
|
+
if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
|
|
117
129
|
const colon = id.indexOf(":");
|
|
118
130
|
return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
|
|
119
131
|
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
|
|
5
5
|
// here at the 200 already. Its text comes back (edit): into an empty
|
|
6
6
|
// composer, to change and send again; over a draft only in a toast (the
|
|
7
|
-
// draft stays)
|
|
8
|
-
//
|
|
7
|
+
// draft stays), and after a switch to another session meanwhile only in a
|
|
8
|
+
// sticky toast naming its session, with Copy. Its images don't come back. A refusal keeps it
|
|
9
|
+
// and says why in a toast. No confirm: nothing is lost. Pure but for the
|
|
9
10
|
// injected calls, so `node --test` covers it; app.js binds onClick to the
|
|
10
11
|
// turn roots.
|
|
11
12
|
import { dequeueTurn as postDequeue } from "./data.js";
|
|
@@ -28,8 +29,9 @@ function clipped(text) {
|
|
|
28
29
|
|
|
29
30
|
// +sessionId+ is read at each click (the open session); +clientId+ is this
|
|
30
31
|
// tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
|
|
31
|
-
// composer when it is empty and says whether it did
|
|
32
|
-
|
|
32
|
+
// composer when it is empty and says whether it did; +copy(text)+ says
|
|
33
|
+
// whether the clipboard took it; +toast(text, action, options)+ as showToast.
|
|
34
|
+
export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false, copy = null }) {
|
|
33
35
|
// A click on a queued bubble's ×; any other click is left alone.
|
|
34
36
|
async function onClick(e) {
|
|
35
37
|
const button = e.target?.closest?.("button.unqueue");
|
|
@@ -44,6 +46,12 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
|
|
|
44
46
|
const result = await dequeue(id, enqueuedId, { clientId });
|
|
45
47
|
removeQueuedBubble(bubble);
|
|
46
48
|
const text = String(result?.prompt || "");
|
|
49
|
+
// Another session opened meanwhile: its composer isn't where this
|
|
50
|
+
// text goes, so only the toast has it.
|
|
51
|
+
if (text.trim() && sessionId() !== id) {
|
|
52
|
+
movedOn(id, text);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
47
55
|
if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
|
|
48
56
|
return result;
|
|
49
57
|
} catch (error) {
|
|
@@ -52,5 +60,13 @@ export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeu
|
|
|
52
60
|
return null;
|
|
53
61
|
}
|
|
54
62
|
}
|
|
63
|
+
// The removed text after a switch: in a sticky toast, its Copy takes the
|
|
64
|
+
// whole text (the toast clips it); a failed copy shows the toast again.
|
|
65
|
+
function movedOn(id, text) {
|
|
66
|
+
const line = `Removed from ${id.slice(0, 8)}: ${clipped(text)}`;
|
|
67
|
+
const show = () => toast(line, copy ? action : null, { sticky: true });
|
|
68
|
+
const action = { label: "Copy", run: async () => { if (await copy(text)) toast("Copied"); else show(); } };
|
|
69
|
+
show();
|
|
70
|
+
}
|
|
55
71
|
return { onClick };
|
|
56
72
|
}
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "worker_idle_exit"
|
|
|
15
15
|
require_relative "worker_wakes"
|
|
16
16
|
require_relative "worker_inbound"
|
|
17
17
|
require_relative "worker_signals"
|
|
18
|
+
require_relative "worker_task_wakes"
|
|
18
19
|
require_relative "session_manager"
|
|
19
20
|
require_relative "archive_store"
|
|
20
21
|
require_relative "log"
|
|
@@ -31,6 +32,7 @@ require_relative "child_reports"
|
|
|
31
32
|
require_relative "child_move"
|
|
32
33
|
require_relative "context_absorber"
|
|
33
34
|
require_relative "context_poller"
|
|
35
|
+
require_relative "task_watch"
|
|
34
36
|
require_relative "tools/task_runtime"
|
|
35
37
|
|
|
36
38
|
module Samagotchi
|
|
@@ -74,6 +76,8 @@ module Samagotchi
|
|
|
74
76
|
# How much of a command's output goes into its :command_ran.
|
|
75
77
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
76
78
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
79
|
+
# /exit held by background tasks a finish wake waits for: how to end them.
|
|
80
|
+
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
77
81
|
# !rollback mid-turn would mean "the turn I'm in".
|
|
78
82
|
ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
|
|
79
83
|
# How long a signalled worker's turn has to end (it is canceled at once,
|
|
@@ -242,7 +246,7 @@ module Samagotchi
|
|
|
242
246
|
# a command queued mid-turn (/thinking, /model, /llm-context) changes
|
|
243
247
|
# what it starts with: no turn-end warm-up then.
|
|
244
248
|
@engine.next_turn_waiting = lambda {
|
|
245
|
-
!@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due?
|
|
249
|
+
!@command_queue.empty? || SessionInbox.find_new_input_files(@session_dir).any? || wake_due? || @task_wakes.due?
|
|
246
250
|
}
|
|
247
251
|
@turn_flow = TurnFlow.new(engine: @engine)
|
|
248
252
|
# Its question's answer is queued as the matching /continue (the
|
|
@@ -258,6 +262,12 @@ module Samagotchi
|
|
|
258
262
|
# The seq of the last turn's end event: a command queued before it was
|
|
259
263
|
# queued while that turn ran.
|
|
260
264
|
@turn_end_seq = 0
|
|
265
|
+
# The background tasks this session started and hasn't seen finish:
|
|
266
|
+
# one that finishes unseen wakes the idle session (#run_task_wake).
|
|
267
|
+
@task_wakes = WorkerTaskWakes.new(engine: @engine, session: @session, wakes: @wakes,
|
|
268
|
+
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
269
|
+
idle: -> { nothing_queued? && !@exit_requested },
|
|
270
|
+
save: -> { save_or_log(:task_notes) { save_session } })
|
|
261
271
|
@engine.subscribe(observer: lambda { |event|
|
|
262
272
|
if Events::TURN_END.include?(event[:type])
|
|
263
273
|
@turn_end_seq = event[:event_seq]
|
|
@@ -269,6 +279,7 @@ module Samagotchi
|
|
|
269
279
|
@turn_ending = false
|
|
270
280
|
end
|
|
271
281
|
ring_question(event) if event[:type] == :question_requested
|
|
282
|
+
@task_wakes.observe(event)
|
|
272
283
|
})
|
|
273
284
|
# This session's own delegate children's news (rings in children/).
|
|
274
285
|
@child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir,
|
|
@@ -315,7 +326,13 @@ module Samagotchi
|
|
|
315
326
|
timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
|
|
316
327
|
input_pending: -> { !@command_queue.empty? || !SessionInbox.find_new_input_files(@session_dir).empty? },
|
|
317
328
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
318
|
-
running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) }
|
|
329
|
+
running_tasks: -> { Tools::TaskRuntime.running_created_in(@engine.messages_checkpoint) },
|
|
330
|
+
# Only when a finish would wake the session (D3): with queue its end
|
|
331
|
+
# is just a note, not worth keeping the worker up for. One that
|
|
332
|
+
# finished since the pass looked holds too, until the next pass
|
|
333
|
+
# wakes for it (#run_task_wake).
|
|
334
|
+
watched_tasks: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.holding : [] },
|
|
335
|
+
watched_running: -> { TaskWatch.mode == "wake" ? @task_wakes.watch.running : [] }
|
|
319
336
|
)
|
|
320
337
|
end
|
|
321
338
|
|
|
@@ -371,12 +388,13 @@ module Samagotchi
|
|
|
371
388
|
run_input_files(input_files)
|
|
372
389
|
end
|
|
373
390
|
|
|
374
|
-
# Nothing queued: a delegate wake turn or a due
|
|
375
|
-
# the worker leaves when the idle exit or a client's
|
|
376
|
-
# it sleeps until woken or the fallback tick.
|
|
391
|
+
# Nothing queued: a delegate wake turn, a task finish wake turn or a due
|
|
392
|
+
# reminder runs, else the worker leaves when the idle exit or a client's
|
|
393
|
+
# exit is due, else it sleeps until woken or the fallback tick.
|
|
377
394
|
# @return [Symbol, nil] as #pass
|
|
378
395
|
def idle_pass
|
|
379
396
|
return if run_wake_turn
|
|
397
|
+
return if run_task_wake
|
|
380
398
|
return if run_due_reminders
|
|
381
399
|
return left(:idle_exit) if @idle_exit.due? && leave_idle
|
|
382
400
|
return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
|
|
@@ -441,6 +459,9 @@ module Samagotchi
|
|
|
441
459
|
memories: @session.preloaded_memory_names,
|
|
442
460
|
muted_memories: @session.muted_memory_names,
|
|
443
461
|
appended_prompt_files: @session.appended_prompt_files,
|
|
462
|
+
# The finish wake reports a task the model leaves running: the tool
|
|
463
|
+
# descriptions say so, fixed for this worker (a stable prompt prefix).
|
|
464
|
+
task_finish_notes: TaskWatch.mode != "off",
|
|
444
465
|
reminders: {
|
|
445
466
|
callback: lambda { |due_names|
|
|
446
467
|
# A reminder is due: the loop runs a reminder turn for it once
|
|
@@ -514,26 +535,14 @@ module Samagotchi
|
|
|
514
535
|
# A turn nobody typed for a changed source (a context wake): a continue
|
|
515
536
|
# turn from context:<name> whose first message is the wake note already
|
|
516
537
|
# in the conversation (named by +turn_id+), on the same rules as a
|
|
517
|
-
# delegate report's wake turn: it counts toward
|
|
518
|
-
# peer's answer toward context.peer_turns too,
|
|
519
|
-
#
|
|
520
|
-
# WorkerInbound#unmark_wake) and pauses wakes until a human's input.
|
|
538
|
+
# delegate report's wake turn (#run_wake): it counts toward
|
|
539
|
+
# session.max_wakes (a peer's answer toward context.peer_turns too,
|
|
540
|
+
# unless it agrees).
|
|
521
541
|
# +context_wake+: a WorkerInbound::ContextWake.
|
|
522
542
|
def run_context_wake_turn(context_wake)
|
|
523
543
|
name = context_wake.name
|
|
524
544
|
Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer_turn?))
|
|
525
|
-
|
|
526
|
-
run_engine_turn(nil, continue: true, origin: context_wake.origin,
|
|
527
|
-
id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
|
|
528
|
-
if error
|
|
529
|
-
kept = @turn_flow.prompt_turn_failed(error: error, wake: "the change in attached context #{name}") == :kept
|
|
530
|
-
# A turn that got somewhere stays, its wake mark with it.
|
|
531
|
-
@inbound.unmark_wake(context_wake) unless kept
|
|
532
|
-
@wakes.pause!
|
|
533
|
-
else
|
|
534
|
-
@continue_offer.after_turn(result)
|
|
535
|
-
end
|
|
536
|
-
end
|
|
545
|
+
run_wake(origin: context_wake.origin, failed: "the change in attached context #{name}", mark: context_wake)
|
|
537
546
|
end
|
|
538
547
|
|
|
539
548
|
# The first prompt (spawn_session's): a delegate child's task, which is
|
|
@@ -552,8 +561,10 @@ module Samagotchi
|
|
|
552
561
|
# @param prompt [WorkerInbound::Prompt]
|
|
553
562
|
def run_prompt(prompt)
|
|
554
563
|
origin = prompt.origin
|
|
555
|
-
# A first turn sees the context attached before it
|
|
564
|
+
# A first turn sees the context attached before it, and any turn the
|
|
565
|
+
# background tasks that finished since the last look.
|
|
556
566
|
absorb_context(before_turn: true)
|
|
567
|
+
@task_wakes.absorb
|
|
557
568
|
user_input(origin&.dig(:client_id))
|
|
558
569
|
@continue_offer.drop(origin)
|
|
559
570
|
@turn_flow.before_prompt_turn
|
|
@@ -621,30 +632,68 @@ module Samagotchi
|
|
|
621
632
|
|
|
622
633
|
@wakes.hand_over(reports)
|
|
623
634
|
Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
|
|
635
|
+
# Rolled back, the rings stay (settle_child_reports): the user's next
|
|
636
|
+
# turn takes them. A turn that got somewhere stays, and read them.
|
|
637
|
+
run_wake(origin: reports.first.origin,
|
|
638
|
+
failed: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
|
|
639
|
+
kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
|
|
640
|
+
"again with the user's next message.")
|
|
641
|
+
true
|
|
642
|
+
ensure
|
|
643
|
+
@wakes.drop_handed
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# A wake turn (a delegate report's, an attached context change's, a
|
|
647
|
+
# finished task's): a continue turn nobody typed, from +origin+. A
|
|
648
|
+
# failure goes back to before the turn and pauses wakes until a human's
|
|
649
|
+
# input (a provider outage doesn't loop); so does a Stop before it got
|
|
650
|
+
# anywhere (a Stop or a before_turn hook's cancel before the model
|
|
651
|
+
# call, restored by TurnFlow), rather than waking again at once. Either
|
|
652
|
+
# way +mark+ (a WorkerInbound::ContextWake or TaskWake: its wake note,
|
|
653
|
+
# the turn's first message) goes back to a plain note
|
|
654
|
+
# (WorkerInbound#unmark_wake), unless the failed turn kept its steps.
|
|
655
|
+
# +failed+ names the wake in the failed turn's note, +kept+ says what
|
|
656
|
+
# happens to it (TurnFlow#prompt_turn_failed).
|
|
657
|
+
def run_wake(origin:, failed:, kept: nil, mark: nil)
|
|
624
658
|
@turn_flow.before_prompt_turn
|
|
625
659
|
restores = @turn_flow.restores
|
|
626
|
-
run_engine_turn(nil, continue: true, origin:
|
|
660
|
+
run_engine_turn(nil, continue: true, origin: origin, id: mark&.turn_id,
|
|
661
|
+
max_iterations: IterationLimit.for) do |result, error|
|
|
627
662
|
if error
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
@turn_flow.prompt_turn_failed(
|
|
631
|
-
error: error, wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
|
|
632
|
-
wake_kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
|
|
633
|
-
"again with the user's next message."
|
|
634
|
-
)
|
|
663
|
+
steps_kept = @turn_flow.prompt_turn_failed(error: error, wake: failed, wake_kept: kept) == :kept
|
|
664
|
+
@inbound.unmark_wake(mark) if mark && !steps_kept
|
|
635
665
|
@wakes.pause!
|
|
636
666
|
else
|
|
637
667
|
@continue_offer.after_turn(result)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
@wakes.pause! if result.canceled? && @turn_flow.restores != restores
|
|
668
|
+
if result.canceled? && @turn_flow.restores != restores
|
|
669
|
+
@inbound.unmark_wake(mark) if mark
|
|
670
|
+
@wakes.pause!
|
|
671
|
+
end
|
|
643
672
|
end
|
|
644
673
|
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# The background tasks this session started that finished unseen, as
|
|
677
|
+
# notes (WorkerTaskWakes#take), and the wake turn the first one may
|
|
678
|
+
# start.
|
|
679
|
+
# @return [Boolean] whether a wake turn ran
|
|
680
|
+
def run_task_wake
|
|
681
|
+
task_wake = @task_wakes.take
|
|
682
|
+
return false unless task_wake
|
|
683
|
+
|
|
684
|
+
run_task_wake_turn(task_wake)
|
|
645
685
|
true
|
|
646
|
-
|
|
647
|
-
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# A turn nobody typed for a finished task: a continue turn from
|
|
689
|
+
# task:<id> whose first message is the wake note, on the delegate wake's
|
|
690
|
+
# rules (#run_wake; counted toward session.max_wakes): a failure that
|
|
691
|
+
# kept nothing, or a Stop that restored the conversation, leaves the
|
|
692
|
+
# note as a plain one (M4).
|
|
693
|
+
def run_task_wake_turn(task_wake)
|
|
694
|
+
Log.info(:worker, "task_wake_turn", task_id: task_wake.id, in_a_row: @wakes.count!)
|
|
695
|
+
run_wake(origin: task_wake.origin, failed: "a finished background task (#{task_wake.name.delete_prefix("task ")})",
|
|
696
|
+
kept: "Its note stays for the user's next message.", mark: task_wake)
|
|
648
697
|
end
|
|
649
698
|
|
|
650
699
|
# The idle loop's sleep: the fallback tick, or less while reports wait
|
|
@@ -901,7 +950,7 @@ module Samagotchi
|
|
|
901
950
|
# @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
|
|
902
951
|
# line that applies it (the TUI's prefill, the web's Apply button)
|
|
903
952
|
def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
|
|
904
|
-
TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
|
|
953
|
+
TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id, status: status)
|
|
905
954
|
text = output.to_s
|
|
906
955
|
event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
|
|
907
956
|
changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
|
|
@@ -1200,13 +1249,19 @@ module Samagotchi
|
|
|
1200
1249
|
# +delete+: the session is deleted after (/exit --delete), so no recap.
|
|
1201
1250
|
# +restart+: a new worker takes over (on the newest chi installed), so
|
|
1202
1251
|
# WorkerIdleExit#hold_for_restart's rules, and no recap or discard.
|
|
1203
|
-
# @return [
|
|
1252
|
+
# @return [WorkerIdleExit::Hold, nil] what keeps the worker up (for
|
|
1253
|
+
# :background_tasks, the watched tasks still running), nil when it
|
|
1254
|
+
# will leave
|
|
1204
1255
|
def exit_request(client_id, delete: false, restart: false)
|
|
1205
1256
|
# The Bridge serves before the idle-exit policy exists.
|
|
1206
|
-
return :starting unless @idle_exit
|
|
1257
|
+
return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
|
|
1207
1258
|
|
|
1208
1259
|
hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
|
|
1209
|
-
|
|
1260
|
+
if hold == :background_tasks && !restart
|
|
1261
|
+
# /exit names the tasks it waits for (D3).
|
|
1262
|
+
return WorkerIdleExit::Hold.new(reason: hold, tasks: @task_wakes.watch.running, hint: TASK_STOP_HINT)
|
|
1263
|
+
end
|
|
1264
|
+
return WorkerIdleExit::Hold.new(reason: hold) if hold
|
|
1210
1265
|
|
|
1211
1266
|
@exit_requested = true
|
|
1212
1267
|
@exit_requested_by = client_id
|
|
@@ -1253,10 +1308,17 @@ module Samagotchi
|
|
|
1253
1308
|
# A restart keeps even an empty session (a new worker takes it over at
|
|
1254
1309
|
# once) and leaves the recap to the conversation's next real pause.
|
|
1255
1310
|
def left(result)
|
|
1311
|
+
deleting = result == :exit_requested && @exit_deletes
|
|
1312
|
+
# The watch goes with this process: a task that finished since the
|
|
1313
|
+
# last look (nothing held the exit for it: queue, a note-only task,
|
|
1314
|
+
# /exit) is a plain note for the next turn, not lost. Not into a
|
|
1315
|
+
# session /exit --delete deletes next: its save could land after the
|
|
1316
|
+
# delete and write the session back.
|
|
1317
|
+
@task_wakes.absorb unless deleting
|
|
1256
1318
|
return result if result == :restart
|
|
1257
1319
|
|
|
1258
1320
|
@discard = empty_session?
|
|
1259
|
-
write_recap_on_leave unless @discard ||
|
|
1321
|
+
write_recap_on_leave unless @discard || deleting
|
|
1260
1322
|
result
|
|
1261
1323
|
end
|
|
1262
1324
|
|
|
@@ -13,6 +13,9 @@ module Samagotchi
|
|
|
13
13
|
# * a reminder is registered. Reminders live only in the Engine and repeat
|
|
14
14
|
# until the agent cancels them, so an exit would drop them for good;
|
|
15
15
|
# * a continue offer waits for an answer; it too lives only in memory;
|
|
16
|
+
# * a background task the worker watches for a finish wake (TaskWatch)
|
|
17
|
+
# still runs, or finished and waits for the next pass's wake: its end
|
|
18
|
+
# would go unnoticed after an exit (D3);
|
|
16
19
|
# * less than the timeout has passed since the worker started, the Engine's
|
|
17
20
|
# last activity, or the last client request or disconnect.
|
|
18
21
|
#
|
|
@@ -21,6 +24,15 @@ module Samagotchi
|
|
|
21
24
|
# the timeout and leaves out the asking client's own stream. A restart
|
|
22
25
|
# (POST /exit restart: true) has its own rules, #hold_for_restart.
|
|
23
26
|
class WorkerIdleExit
|
|
27
|
+
# What keeps the worker up when a client asks it to exit (Worker#exit_request,
|
|
28
|
+
# the Bridge's 409): +reason+, a #hold_for_request (or #hold_for_restart)
|
|
29
|
+
# Symbol, or :starting before this policy exists; for :background_tasks
|
|
30
|
+
# the watched tasks still running ({id:, command:}) and a +hint+ on
|
|
31
|
+
# stopping them.
|
|
32
|
+
Hold = Data.define(:reason, :tasks, :hint) do
|
|
33
|
+
def initialize(reason:, tasks: nil, hint: nil) = super
|
|
34
|
+
end
|
|
35
|
+
|
|
24
36
|
# @param engine [Engine] #turn_running?, #last_activity_at, #reminder_store
|
|
25
37
|
# @param bridge [Bridge, nil] #open_streams, #last_client_activity_at; nil
|
|
26
38
|
# when the worker's Bridge failed to start
|
|
@@ -29,15 +41,23 @@ module Samagotchi
|
|
|
29
41
|
# @param awaiting_continue [#call] true while a continue offer waits
|
|
30
42
|
# @param running_tasks [#call] the background tasks this conversation
|
|
31
43
|
# started that still run (Tools::TaskRuntime.running_created_in)
|
|
44
|
+
# @param watched_tasks [#call] the ones a finish wake would wake for,
|
|
45
|
+
# running or not yet taken (TaskWatch#holding): they hold the idle exit
|
|
46
|
+
# @param watched_running [#call] those of them still running
|
|
47
|
+
# (TaskWatch#running): they hold /exit (one that finished is a note
|
|
48
|
+
# as the worker leaves)
|
|
32
49
|
# @param clock [#call] monotonic seconds, the Engine's and Bridge's clock
|
|
33
50
|
def initialize(engine:, bridge:, timeout_minutes:, input_pending:, awaiting_continue: -> { false },
|
|
34
|
-
running_tasks: -> { [] },
|
|
51
|
+
running_tasks: -> { [] }, watched_tasks: -> { [] }, watched_running: -> { [] },
|
|
52
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
35
53
|
@engine = engine
|
|
36
54
|
@bridge = bridge
|
|
37
55
|
@timeout = timeout_minutes.to_f * 60
|
|
38
56
|
@input_pending = input_pending
|
|
39
57
|
@awaiting_continue = awaiting_continue
|
|
40
58
|
@running_tasks = running_tasks
|
|
59
|
+
@watched_tasks = watched_tasks
|
|
60
|
+
@watched_running = watched_running
|
|
41
61
|
@clock = clock
|
|
42
62
|
@started_at = clock.call
|
|
43
63
|
end
|
|
@@ -49,7 +69,8 @@ module Samagotchi
|
|
|
49
69
|
|
|
50
70
|
# What keeps the worker up, or nil when nothing does.
|
|
51
71
|
# @return [Symbol, nil] :disabled, :turn_running, :input_queued,
|
|
52
|
-
# :continue_offered, :client_connected, :reminders
|
|
72
|
+
# :continue_offered, :client_connected, :reminders, :background_tasks
|
|
73
|
+
# or :recent_activity
|
|
53
74
|
def hold
|
|
54
75
|
return :disabled unless @timeout.positive?
|
|
55
76
|
return :turn_running if @engine.turn_running?
|
|
@@ -57,6 +78,7 @@ module Samagotchi
|
|
|
57
78
|
return :continue_offered if @awaiting_continue.call
|
|
58
79
|
return :client_connected if @bridge && @bridge.open_streams.positive?
|
|
59
80
|
return :reminders if @engine.reminder_store&.any?
|
|
81
|
+
return :background_tasks if @watched_tasks.call.any?
|
|
60
82
|
return :recent_activity if idle_seconds < @timeout
|
|
61
83
|
|
|
62
84
|
nil
|
|
@@ -71,13 +93,14 @@ module Samagotchi
|
|
|
71
93
|
# either (the last check before leaving, when the asker's stream may
|
|
72
94
|
# still be open, untagged)
|
|
73
95
|
# @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
|
|
74
|
-
# :client_connected or :
|
|
96
|
+
# :client_connected, :reminders or :background_tasks
|
|
75
97
|
def hold_for_request(requester:, streams: true)
|
|
76
98
|
return :turn_running if @engine.turn_running?
|
|
77
99
|
return :input_queued if @input_pending.call
|
|
78
100
|
return :continue_offered if @awaiting_continue.call
|
|
79
101
|
return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
|
|
80
102
|
return :reminders if @engine.reminder_store&.any?
|
|
103
|
+
return :background_tasks if @watched_running.call.any?
|
|
81
104
|
|
|
82
105
|
nil
|
|
83
106
|
end
|