samagotchi 0.59.0 → 0.60.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 +49 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +4 -2
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +10 -4
- data/docs/internals/engine.md +16 -8
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +3 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +50 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +63 -14
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +17 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/worker.rb +1 -0
- metadata +6 -1
data/docs/sessions.md
CHANGED
|
@@ -70,7 +70,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
70
70
|
- `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
|
|
71
71
|
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
72
72
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
73
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
73
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
74
74
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
75
75
|
|
|
76
76
|
**Test-session hygiene:**
|
data/docs/sub-agent.md
CHANGED
|
@@ -55,8 +55,8 @@ command, folder, rule and reason, or `continue`, the step-limit question below.
|
|
|
55
55
|
|
|
56
56
|
## The step limit
|
|
57
57
|
|
|
58
|
-
A turn that runs out of steps (`turn.max_iterations`, default
|
|
59
|
-
answers waits as a question of kind `continue` (`"limit":
|
|
58
|
+
A turn that runs out of steps (`turn.max_iterations`, default 300) before it
|
|
59
|
+
answers waits as a question of kind `continue` (`"limit": 300`, 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
|
data/docs/testing.md
CHANGED
|
@@ -107,8 +107,10 @@ sessions by model and by the model notes their prompt carried (the session file'
|
|
|
107
107
|
prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
|
|
108
108
|
first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
|
|
109
109
|
requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
|
|
110
|
-
outside: Continues (the turns in its `analytics.json` marked `
|
|
111
|
-
2026-10-
|
|
110
|
+
outside: Continues (the step-limit Continues: the turns in its `analytics.json` marked `resumed: true`, the mark a
|
|
111
|
+
file written after 2026-10-11 carries; a file from 2026-10-09 on has `continue: true` only, which a reminder's and a
|
|
112
|
+
wake's turn carry too, and that is counted instead, record by record in a file that spans both; an older file has neither, and its turns with no prompt of their
|
|
113
|
+
own are counted), steers (lines that reached a
|
|
112
114
|
running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
|
|
113
115
|
group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
|
|
114
116
|
each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
|
|
@@ -41,7 +41,8 @@ module Samagotchi
|
|
|
41
41
|
With no config.yml it writes one; an existing one gets a hosts:
|
|
42
42
|
entry added (after a backup), and its other lines stay as they are.
|
|
43
43
|
Then it installs the system bundle and the core bundles (loop-guard,
|
|
44
|
-
check-in, guardrails), and on a terminal
|
|
44
|
+
check-in, guardrails, slow-commands, agents-md), and on a terminal
|
|
45
|
+
offers the dev bundles.
|
|
45
46
|
TEXT
|
|
46
47
|
|
|
47
48
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
@@ -118,6 +118,7 @@ module Samagotchi
|
|
|
118
118
|
return [] unless turn
|
|
119
119
|
|
|
120
120
|
started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
|
|
121
|
+
started[:resumed] = true if turn[:resumed]
|
|
121
122
|
started[:started_at] = started_at if started_at
|
|
122
123
|
events = [with_images(started, turn[:images])]
|
|
123
124
|
text_iteration = nil # a text part is open for this iteration
|
|
@@ -238,6 +239,7 @@ module Samagotchi
|
|
|
238
239
|
dequeue([event[:origin]])
|
|
239
240
|
@turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
|
|
240
241
|
parts: [], pending_question: nil }
|
|
242
|
+
@turn[:resumed] = true if event[:resumed]
|
|
241
243
|
@tool_started_at = {}
|
|
242
244
|
@turn[:images] = event[:images] if event[:images]
|
|
243
245
|
@turn[:cut_after] = event[:cut_after] if event[:cut_after]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agents-md
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "Steers a nested AGENTS.md into the conversation the first time a tool touches a file under its directory"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:fba1b4b48fa99299aa4c5b56999f5083513f7eb22d85f3fd2a5c2dbd0d97c707
|
|
10
|
+
# ctx.prompt_files, read when the plugin loads (0.60.0)
|
|
11
|
+
requires_chi: ">= 0.60.0"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The agents-md bundle (docs/plugins.md, The agents-md bundle): a nested
|
|
4
|
+
# AGENTS.md of the session's checkout is steered into the conversation the
|
|
5
|
+
# first time a tool touches a file under its directory.
|
|
6
|
+
#
|
|
7
|
+
# chi's system prompt holds only the AGENTS.md chain from the checkout top
|
|
8
|
+
# down to the session's cwd (SystemPrompt#prompt_files), so an AGENTS.md in
|
|
9
|
+
# a directory the session doesn't run in (pkg/inner/AGENTS.md while the
|
|
10
|
+
# session runs in pkg/) is never seen. Working on a nested file usually
|
|
11
|
+
# means its directory's rules apply, so the first ok `read`, `write` or
|
|
12
|
+
# `edit` (settings tools) of such a file steers the nearest AGENTS.md into
|
|
13
|
+
# the running turn, at the loop's next boundary, as its own user message:
|
|
14
|
+
#
|
|
15
|
+
# AGENTS.md for pkg/inner/ (applies to work under pkg/inner/):
|
|
16
|
+
# <content>
|
|
17
|
+
#
|
|
18
|
+
# It is history, not system prompt: the prompt cache is untouched, and a
|
|
19
|
+
# resume, a woken worker or a fork (which copies the messages) never
|
|
20
|
+
# re-injects — a before_turn pass over event[:messages] replaces the loaded
|
|
21
|
+
# set with the dirs whose steer is already in the conversation. A steer
|
|
22
|
+
# that was queued but dropped (the model answered first) is not in the
|
|
23
|
+
# messages, so it fires again on the next touch. It never stops generation
|
|
24
|
+
# or thinking; a plugin steer can't cut (SteerCut#cut_for_steer).
|
|
25
|
+
#
|
|
26
|
+
# Paths are compared by File.realpath, falling back to File.expand_path for
|
|
27
|
+
# a path that doesn't exist: the session's cwd can be /tmp/x on macOS while
|
|
28
|
+
# git and the prompt's files give /private/tmp/x. The touched file's own git
|
|
29
|
+
# root must equal the session's checkout (ctx.repo_root): a file in another
|
|
30
|
+
# repository, a nested repository or a submodule is ignored, and the walk up
|
|
31
|
+
# stops at that root. Directories whose AGENTS.md is already in the system
|
|
32
|
+
# prompt (ctx.prompt_files: the chain and appended files) or already steered
|
|
33
|
+
# are skipped.
|
|
34
|
+
#
|
|
35
|
+
# Settings (config.yml, bundles: agents-md:):
|
|
36
|
+
# tools: [read, write, edit] the calls that count as a touch
|
|
37
|
+
# max_chars: 16000 the content is cut at this many chars, the
|
|
38
|
+
# last line "[cut at N of M chars: read <path>
|
|
39
|
+
# for the rest]" naming the file by its absolute path
|
|
40
|
+
#
|
|
41
|
+
# skip_agents_md: true (top-level) disables the plugin, as it does the
|
|
42
|
+
# chain's loading.
|
|
43
|
+
|
|
44
|
+
require "samagotchi/config"
|
|
45
|
+
require "samagotchi/guardrails/context"
|
|
46
|
+
|
|
47
|
+
class Plugin
|
|
48
|
+
FILE_NAME = "AGENTS.md"
|
|
49
|
+
DEFAULT_TOOLS = %w[read write edit].freeze
|
|
50
|
+
DEFAULT_MAX_CHARS = 16_000
|
|
51
|
+
# The marker on the steer's first line (and what #rebuild matches):
|
|
52
|
+
# <rel dir>/ is relative to the checkout top, so a dir is unique.
|
|
53
|
+
MARKER = %r{\AAGENTS\.md for (.+)/ \(applies to work under \1/\):\n}
|
|
54
|
+
|
|
55
|
+
def initialize(settings = {})
|
|
56
|
+
tools = settings["tools"]
|
|
57
|
+
@tools = if tools.nil?
|
|
58
|
+
DEFAULT_TOOLS
|
|
59
|
+
elsif tools.is_a?(Array)
|
|
60
|
+
tools.map(&:to_s)
|
|
61
|
+
else
|
|
62
|
+
tools.to_s.split(/\s+/).reject(&:empty?)
|
|
63
|
+
end
|
|
64
|
+
chars = settings["max_chars"]
|
|
65
|
+
chars = chars.to_i if chars
|
|
66
|
+
@max_chars = chars.is_a?(Integer) && chars.positive? ? chars : DEFAULT_MAX_CHARS
|
|
67
|
+
@git = Samagotchi::Guardrails::GitInfo.new
|
|
68
|
+
# session_id => {rel dir => true}: the dirs steered this session.
|
|
69
|
+
@loaded = Hash.new { |hash, id| hash[id] = {} }
|
|
70
|
+
@lock = Mutex.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def register(chi)
|
|
74
|
+
# Replaces the loaded set from the conversation: a dropped steer fires
|
|
75
|
+
# again on the next touch, a resume or a fork rebuilds from its messages.
|
|
76
|
+
chi.on(:before_turn) { |event, ctx| rebuild(event, ctx) }
|
|
77
|
+
chi.on(:after_tool_call) { |event, ctx| on_tool(event, ctx) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# An ok touch of a file under a directory with a nested AGENTS.md steers
|
|
81
|
+
# each candidate (the file's directory up to, not including, the checkout
|
|
82
|
+
# top; outermost first) once per session.
|
|
83
|
+
def on_tool(event, ctx)
|
|
84
|
+
return if Samagotchi::Config.get("skip_agents_md") == true
|
|
85
|
+
return unless @tools.include?(event[:tool].to_s)
|
|
86
|
+
return unless event[:status].to_s == "ok"
|
|
87
|
+
|
|
88
|
+
call = event[:call] || {}
|
|
89
|
+
# The path a hook sees (docs/hooks.md): read on content:, write/edit on
|
|
90
|
+
# path:, expanded against the session's cwd.
|
|
91
|
+
raw = (event[:tool].to_s == "read" ? call[:content] : call[:path]).to_s.strip
|
|
92
|
+
return if raw.empty?
|
|
93
|
+
|
|
94
|
+
path = resolve(raw, base: ctx.cwd)
|
|
95
|
+
top = resolve(ctx.repo_root)
|
|
96
|
+
return if top.nil?
|
|
97
|
+
|
|
98
|
+
# Scope: the file's own git root must be the session's checkout (a
|
|
99
|
+
# nested repository, a submodule or another repository is ignored).
|
|
100
|
+
file_root = resolve(@git.root(File.dirname(path)))
|
|
101
|
+
return unless file_root && file_root == top
|
|
102
|
+
|
|
103
|
+
prompt_files = Set.new(ctx.prompt_files.map { |file| resolve(file) })
|
|
104
|
+
count = 0
|
|
105
|
+
candidate_dirs(path, top).reverse_each do |dir|
|
|
106
|
+
agents = File.join(dir, FILE_NAME)
|
|
107
|
+
next unless File.file?(agents)
|
|
108
|
+
next if prompt_files.include?(resolve(agents)) # in the system prompt already
|
|
109
|
+
next if loaded(ctx).key?(relative_dir(dir, top))
|
|
110
|
+
|
|
111
|
+
text = steer_text(dir, top, agents)
|
|
112
|
+
next unless text
|
|
113
|
+
|
|
114
|
+
# Queued: the dir is loaded; a steer dropped before it was injected
|
|
115
|
+
# (the model answered first) is not queued, so it fires again later.
|
|
116
|
+
count += 1 if ctx.steer(text) && (loaded(ctx)[relative_dir(dir, top)] = true)
|
|
117
|
+
end
|
|
118
|
+
ctx.log.info(:agents_md_steered, file: File.basename(path), n: count) if count.positive?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The dirs between +path+'s directory and +top+, file's directory first
|
|
124
|
+
# (the caller reverses for outermost-first).
|
|
125
|
+
def candidate_dirs(path, top)
|
|
126
|
+
dirs = []
|
|
127
|
+
dir = File.dirname(path)
|
|
128
|
+
prefix = top.end_with?("/") ? top : "#{top}/"
|
|
129
|
+
while dir != top && dir.start_with?(prefix)
|
|
130
|
+
dirs << dir
|
|
131
|
+
parent = File.dirname(dir)
|
|
132
|
+
break if parent == dir
|
|
133
|
+
|
|
134
|
+
dir = parent
|
|
135
|
+
end
|
|
136
|
+
dirs
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# +dir+ relative to +top+ ("." for +top+ itself), or nil when it isn't under it.
|
|
140
|
+
def relative_dir(dir, top)
|
|
141
|
+
prefix = top.end_with?("/") ? top : "#{top}/"
|
|
142
|
+
return "." if dir == top
|
|
143
|
+
return nil unless dir.start_with?(prefix)
|
|
144
|
+
|
|
145
|
+
dir.delete_prefix(prefix)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The steer: the marker first line (what #rebuild matches), the content,
|
|
149
|
+
# cut at max_chars with a pointer at the end. Nil when there is nothing to say.
|
|
150
|
+
def steer_text(dir, top, agents)
|
|
151
|
+
content = File.read(agents, encoding: "UTF-8")
|
|
152
|
+
return nil if content.strip.empty?
|
|
153
|
+
|
|
154
|
+
rel_dir = relative_dir(dir, top)
|
|
155
|
+
header = "AGENTS.md for #{rel_dir}/ (applies to work under #{rel_dir}/):"
|
|
156
|
+
return "#{header}\n#{content}" if content.length <= @max_chars
|
|
157
|
+
|
|
158
|
+
body = content[0...@max_chars]
|
|
159
|
+
"#{header}\n#{body}\n[cut at #{@max_chars} of #{content.length} chars: read #{agents} for the rest]"
|
|
160
|
+
rescue SystemCallError, EncodingError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Replaces the loaded set with the dirs whose steer the conversation
|
|
165
|
+
# already carries (a user message, kind steer, source agents-md, the
|
|
166
|
+
# marker on its first line).
|
|
167
|
+
def rebuild(event, ctx)
|
|
168
|
+
id = event[:session_id] || ctx.session_id
|
|
169
|
+
found = {}
|
|
170
|
+
Array(event[:messages]).each do |message|
|
|
171
|
+
next unless message.is_a?(Hash)
|
|
172
|
+
next unless (message[:role] || message["role"]).to_s == "user"
|
|
173
|
+
next unless (message[:kind] || message["kind"]).to_s == "steer"
|
|
174
|
+
next unless (message[:source] || message["source"]).to_s == "agents-md"
|
|
175
|
+
|
|
176
|
+
if (match = MARKER.match((message[:content] || message["content"]).to_s))
|
|
177
|
+
found[match[1]] = true
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
@lock.synchronize { @loaded[id] = found }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# +path+ (relative ones against +base+) by File.realpath, falling back to
|
|
184
|
+
# File.expand_path when it doesn't exist; nil for an empty one.
|
|
185
|
+
def resolve(path, base: nil)
|
|
186
|
+
path = path.to_s
|
|
187
|
+
return nil if path.empty?
|
|
188
|
+
|
|
189
|
+
path = File.expand_path(path, base) if base && !File.absolute_path?(path)
|
|
190
|
+
File.realpath(path)
|
|
191
|
+
rescue SystemCallError
|
|
192
|
+
File.expand_path(path, base || Dir.pwd)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def loaded(ctx)
|
|
196
|
+
@lock.synchronize { @loaded[ctx.session_id] }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
121
121
|
1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
|
|
122
122
|
2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
|
|
123
123
|
3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
|
|
124
|
-
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
|
|
124
|
+
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:`/`append_system_prompt:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
|
|
125
125
|
5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
|
|
126
126
|
6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
|
|
127
127
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.60.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
|
|
10
10
|
memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -223,7 +223,7 @@ module Samagotchi
|
|
|
223
223
|
Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
|
|
224
224
|
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
225
225
|
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
226
|
-
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default:
|
|
226
|
+
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 300, expose: %i[env config]),
|
|
227
227
|
# Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
|
|
228
228
|
# config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
|
|
229
229
|
Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
|
|
@@ -272,10 +272,10 @@ module Samagotchi
|
|
|
272
272
|
Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
|
|
273
273
|
].freeze
|
|
274
274
|
|
|
275
|
-
# Top-level maps read by their own code, whose entry names and
|
|
276
|
-
# are the user's: ConfigFile.model_aliases / preloaded_memories
|
|
277
|
-
# Hooks::Loader, Engine#read_bundle_settings.
|
|
278
|
-
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
|
|
275
|
+
# Top-level maps and lists read by their own code, whose entry names and
|
|
276
|
+
# contents are the user's: ConfigFile.model_aliases / preloaded_memories /
|
|
277
|
+
# appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
|
|
278
|
+
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
|
|
279
279
|
# Maps of named entries and the keys an entry may hold
|
|
280
280
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
281
281
|
MAP_ENTRY_KEYS = {
|
|
@@ -987,6 +987,34 @@ module Samagotchi
|
|
|
987
987
|
[]
|
|
988
988
|
end
|
|
989
989
|
|
|
990
|
+
# Resolve the `append_system_prompt:` list: files whose text goes into
|
|
991
|
+
# the system prompt after AGENTS.md (SystemPrompt). A YAML list of
|
|
992
|
+
# strings or a single string; ~ is expanded, a relative entry is kept as
|
|
993
|
+
# written (resolved against the cwd the prompt is built in). [] when
|
|
994
|
+
# the key is absent or malformed.
|
|
995
|
+
# @return [Array<String>]
|
|
996
|
+
def appended_prompt_files(env: ENV, path: global_path(env: env))
|
|
997
|
+
data = read_yaml(env: env, path: path)
|
|
998
|
+
raw = data["append_system_prompt"] if data.is_a?(Hash)
|
|
999
|
+
items = case raw
|
|
1000
|
+
when Array then raw
|
|
1001
|
+
when String then [raw]
|
|
1002
|
+
else []
|
|
1003
|
+
end
|
|
1004
|
+
items.select { |v| v.is_a?(String) }.map(&:strip).reject(&:empty?).filter_map { |v| expand_appended_entry(v) }
|
|
1005
|
+
rescue StandardError
|
|
1006
|
+
[]
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# An append_system_prompt: entry with ~ expanded; nil (skipped, logged)
|
|
1010
|
+
# for a ~user that doesn't exist, so the other entries still load.
|
|
1011
|
+
def expand_appended_entry(entry)
|
|
1012
|
+
entry.start_with?("~") ? File.expand_path(entry) : entry
|
|
1013
|
+
rescue ArgumentError => e
|
|
1014
|
+
Samagotchi::Log.warn(:config, "appended_prompt_file_skipped", entry: entry, error: e.message) if defined?(Samagotchi::Log)
|
|
1015
|
+
nil
|
|
1016
|
+
end
|
|
1017
|
+
|
|
990
1018
|
# Parse a model string that may be qualified as "host_alias:model"
|
|
991
1019
|
# Returns [host_alias_or_nil, bare_model] (ModelRef.split)
|
|
992
1020
|
def parse_host_qualified_model(raw, hosts: nil)
|
|
@@ -111,13 +111,15 @@ module Samagotchi
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
# The continue offer was answered yes: resume the conversation without a
|
|
114
|
-
# user message, with the iteration limit the offer's turn had
|
|
114
|
+
# user message, with the iteration limit the offer's turn had, from the
|
|
115
|
+
# run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
|
|
115
116
|
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
116
117
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
117
118
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
118
119
|
# never began leaves it to no other.
|
|
119
120
|
def run_continue_turn(command)
|
|
120
121
|
offer = @turn_flow.offer
|
|
122
|
+
resume_from = @turn_flow.resume_from
|
|
121
123
|
close("answered")
|
|
122
124
|
steer = @continue_steer
|
|
123
125
|
@continue_steer = nil
|
|
@@ -129,7 +131,8 @@ module Samagotchi
|
|
|
129
131
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
130
132
|
return announce_offer(offer)
|
|
131
133
|
end
|
|
132
|
-
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
|
|
134
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
|
|
135
|
+
resume_from: resume_from,
|
|
133
136
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
134
137
|
if error
|
|
135
138
|
announce_offer(offer)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -105,11 +105,13 @@ module Samagotchi
|
|
|
105
105
|
# @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
|
|
106
106
|
# @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
|
|
107
107
|
# prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
|
|
108
|
+
# @param appended_prompt_files [Array<String>] --append-system-prompt's files (absolute paths; a session's
|
|
109
|
+
# appended_prompt_files): their text goes into the system prompt after config.yml's append_system_prompt:
|
|
108
110
|
# @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
|
|
109
111
|
# @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
|
|
110
112
|
# delegate (a child would outlive it) nor plugin fork
|
|
111
113
|
def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
|
|
112
|
-
plugins: true, scratch: false, model_typed: nil)
|
|
114
|
+
plugins: true, scratch: false, model_typed: nil, appended_prompt_files: [])
|
|
113
115
|
# The turn as other threads see it (flag, cancel controller, sink,
|
|
114
116
|
# steers) and the idle layer's activity clock. Built first: a plugin
|
|
115
117
|
# may steer or ask whether a turn runs while it loads.
|
|
@@ -205,7 +207,7 @@ module Samagotchi
|
|
|
205
207
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
206
208
|
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
207
209
|
model_notes: -> { model_notes }, memories: memories,
|
|
208
|
-
muted_memory_names: @muted_memory_names)
|
|
210
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
|
|
209
211
|
@session = nil
|
|
210
212
|
@session_observer = SessionObserver.new
|
|
211
213
|
@metrics = SessionMetrics.new
|
|
@@ -1013,7 +1015,8 @@ module Samagotchi
|
|
|
1013
1015
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
1014
1016
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
1015
1017
|
thinking: thinking_explained&.summary,
|
|
1016
|
-
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1018
|
+
reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h),
|
|
1019
|
+
prompt_files: @prompt_builder.prompt_files, price_host: target.entry.name)
|
|
1017
1020
|
unless snapshot.dig(:context, :window_tokens)
|
|
1018
1021
|
window = current_context_window(target)
|
|
1019
1022
|
if window
|
|
@@ -1518,7 +1521,7 @@ module Samagotchi
|
|
|
1518
1521
|
private :hook_source
|
|
1519
1522
|
|
|
1520
1523
|
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1521
|
-
NOTICE_FALLBACKS = %i[display].freeze
|
|
1524
|
+
NOTICE_FALLBACKS = %i[display editor].freeze
|
|
1522
1525
|
|
|
1523
1526
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1524
1527
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
@@ -1527,9 +1530,10 @@ module Samagotchi
|
|
|
1527
1530
|
#
|
|
1528
1531
|
# +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
|
|
1529
1532
|
# :display, the answer's display (AnswerDisplay), for a UI that doesn't
|
|
1530
|
-
# render its links
|
|
1531
|
-
#
|
|
1532
|
-
#
|
|
1533
|
+
# render its links; :editor, a ref the viewer could open in its editor
|
|
1534
|
+
# (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
|
|
1535
|
+
# does leaves the line out; the others, the log among them, show it. An
|
|
1536
|
+
# unknown value is dropped, so the line shows everywhere.
|
|
1533
1537
|
def hook_notify(text, level, hook, fallback_for: nil)
|
|
1534
1538
|
level = (level || :info).to_sym
|
|
1535
1539
|
level = :info unless %i[info warn].include?(level)
|
|
@@ -1801,8 +1805,14 @@ module Samagotchi
|
|
|
1801
1805
|
# +id+: names the turn on :turn_started (its metrics record's id) and on
|
|
1802
1806
|
# its saved prompt, so a UI pairs the two whatever left the conversation.
|
|
1803
1807
|
# +limit+: the iteration limit #generate ran it with.
|
|
1808
|
+
# +resumed+: a step-limit Continue (run_turn's resumed:).
|
|
1809
|
+
# +resume_from+: where its reasoning starts, the run it resumes
|
|
1810
|
+
# (run_turn's resume_from:); nil for every other turn, and for a
|
|
1811
|
+
# Continue that starts cold (after a model change).
|
|
1804
1812
|
Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
|
|
1805
|
-
:settings, :id, :limit) do
|
|
1813
|
+
:settings, :id, :limit, :resumed, :resume_from) do
|
|
1814
|
+
def resumed? = resumed ? true : false
|
|
1815
|
+
|
|
1806
1816
|
# The turn's boundary events carry the origin only when there is one,
|
|
1807
1817
|
# so payloads stay unchanged for callers that don't pass it.
|
|
1808
1818
|
def tag(event) = origin ? event.merge(origin: origin) : event
|
|
@@ -1832,6 +1842,14 @@ module Samagotchi
|
|
|
1832
1842
|
# @param continue [Boolean] resume the conversation without appending a
|
|
1833
1843
|
# user prompt (continue after the iteration limit, reminder turns);
|
|
1834
1844
|
# +prompt+ is ignored and :turn_started carries `continue: true`
|
|
1845
|
+
# @param resumed [Boolean] a step-limit Continue (with continue:):
|
|
1846
|
+
# :turn_started carries `resumed: true`, so do the before_turn payload
|
|
1847
|
+
# and the turn's analytics record. false for every other continue (a
|
|
1848
|
+
# reminder, a wake).
|
|
1849
|
+
# @param resume_from [Integer, nil] a step-limit Continue's: where the
|
|
1850
|
+
# run it resumes started (ModelResult#run_start, kept in the offer by
|
|
1851
|
+
# TurnFlow), so it keeps that run's reasoning; nil starts cold (a
|
|
1852
|
+
# model change while the offer stood, every other turn).
|
|
1835
1853
|
# @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
|
|
1836
1854
|
# when given, the turn's boundary events (:turn_started, :turn_completed,
|
|
1837
1855
|
# :turn_canceled, :turn_failed) carry it as `origin:`
|
|
@@ -1854,9 +1872,13 @@ module Samagotchi
|
|
|
1854
1872
|
# re-raises; a provider error adds error_kind:, retryable:, host: and a
|
|
1855
1873
|
# one-line summary:.
|
|
1856
1874
|
def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
|
|
1857
|
-
images: [], id: nil)
|
|
1875
|
+
images: [], id: nil, resumed: false, resume_from: nil)
|
|
1858
1876
|
turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
|
|
1859
1877
|
continue: continue, id: id)
|
|
1878
|
+
if continue
|
|
1879
|
+
turn.resumed = resumed
|
|
1880
|
+
turn.resume_from = resume_from
|
|
1881
|
+
end
|
|
1860
1882
|
begin
|
|
1861
1883
|
# A Stop cuts the /props probes this thread makes for the turn
|
|
1862
1884
|
# (window, served model, vision) instead of waiting their timeout.
|
|
@@ -1939,6 +1961,7 @@ module Samagotchi
|
|
|
1939
1961
|
bind_metrics(session)
|
|
1940
1962
|
turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
|
|
1941
1963
|
turn_started[:continue] = true if turn.continue
|
|
1964
|
+
turn_started[:resumed] = true if turn.resumed?
|
|
1942
1965
|
turn_started[:images] = image_refs unless image_refs.empty?
|
|
1943
1966
|
turn_started[:cut_after] = steer_cut_after
|
|
1944
1967
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
@@ -1980,9 +2003,9 @@ module Samagotchi
|
|
|
1980
2003
|
end
|
|
1981
2004
|
|
|
1982
2005
|
# Fire :before_turn hook, with a read-only copy of the history so far
|
|
1983
|
-
# and the prompt (nil on a continue).
|
|
2006
|
+
# and the prompt (nil on a continue); resumed: a step-limit Continue.
|
|
1984
2007
|
@hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
|
|
1985
|
-
messages: hook_messages(session.messages) })
|
|
2008
|
+
resumed: turn.resumed?, messages: hook_messages(session.messages) })
|
|
1986
2009
|
|
|
1987
2010
|
turn_messages(turn, image_refs)
|
|
1988
2011
|
true
|
|
@@ -2118,18 +2141,39 @@ module Samagotchi
|
|
|
2118
2141
|
log_llm_context(explained)
|
|
2119
2142
|
|
|
2120
2143
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
2121
|
-
|
|
2144
|
+
# Where this run starts (ChatLoop::Run's start): an offer made at its
|
|
2145
|
+
# end keeps it, so a Continue resumes this run.
|
|
2146
|
+
run_start = turn.messages.length
|
|
2147
|
+
reasoning_since = resumed_run_start(turn, run_start)
|
|
2148
|
+
result = backend.complete(
|
|
2122
2149
|
messages: turn.messages,
|
|
2123
2150
|
max_iterations: turn.limit,
|
|
2124
2151
|
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
|
|
2125
2152
|
cancel_controller: turn.controller,
|
|
2126
2153
|
model_name: bare_for_backend,
|
|
2127
2154
|
max_tool_output_chars: max_tool_output_chars,
|
|
2128
|
-
pending_input: turn_drain(pending_input)
|
|
2155
|
+
pending_input: turn_drain(pending_input),
|
|
2156
|
+
reasoning_since: reasoning_since
|
|
2129
2157
|
)
|
|
2158
|
+
result.run_start = run_start
|
|
2159
|
+
result
|
|
2130
2160
|
end
|
|
2131
2161
|
private :generate
|
|
2132
2162
|
|
|
2163
|
+
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2164
|
+
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2165
|
+
# Entries before it never move between the turns (#turn_messages adds
|
|
2166
|
+
# at the tail, and the system head is replaced in place).
|
|
2167
|
+
def resumed_run_start(turn, run_start)
|
|
2168
|
+
start = turn.resume_from
|
|
2169
|
+
return nil if start.nil?
|
|
2170
|
+
return start if start.is_a?(Integer) && start.between?(0, run_start)
|
|
2171
|
+
|
|
2172
|
+
Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
|
|
2173
|
+
nil
|
|
2174
|
+
end
|
|
2175
|
+
private :resumed_run_start
|
|
2176
|
+
|
|
2133
2177
|
# The turn's LLM context strategy and where it came from, in the turn
|
|
2134
2178
|
# log: when it runs a layer, or the session set it (none too).
|
|
2135
2179
|
def log_llm_context(explained)
|
|
@@ -2203,7 +2247,8 @@ module Samagotchi
|
|
|
2203
2247
|
# replace). Runs under the event lock: it also adds the turn's note to
|
|
2204
2248
|
# result.conversation, which the REPL keeps as-is.
|
|
2205
2249
|
# A turn that ran out of iterations ends at its tool results, so a
|
|
2206
|
-
# continue resumes from them rather than after a made-up reply
|
|
2250
|
+
# continue resumes from them rather than after a made-up reply (and a
|
|
2251
|
+
# step-limit Continue from its run's start: ModelResult#run_start).
|
|
2207
2252
|
def kept_messages(turn, result, seconds)
|
|
2208
2253
|
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
2209
2254
|
# Nothing visible (no text) is a turn the model should know ended
|
|
@@ -2624,6 +2669,10 @@ module Samagotchi
|
|
|
2624
2669
|
state_dir: -> { session_state_dir },
|
|
2625
2670
|
scratch: -> { @scratch },
|
|
2626
2671
|
setup: -> { SessionSetup.of(@session) },
|
|
2672
|
+
# The files whose text the built system prompt holds; [] until a
|
|
2673
|
+
# build and after a rebuild that drops them (SystemPrompt#prompt_files
|
|
2674
|
+
# returns [] itself: the || keeps a nil builder safe).
|
|
2675
|
+
prompt_files: -> { @prompt_builder&.prompt_files || [] },
|
|
2627
2676
|
frontend: -> { interface }
|
|
2628
2677
|
)
|
|
2629
2678
|
end
|
|
@@ -10,8 +10,9 @@ module Samagotchi
|
|
|
10
10
|
module IterationLimit
|
|
11
11
|
KEY = "turn.max_iterations"
|
|
12
12
|
# A turn's limit unless turn.max_iterations says otherwise, and the
|
|
13
|
-
# loops' own default.
|
|
14
|
-
|
|
13
|
+
# loops' own default. Loops are caught by the loop-guard bundle and a
|
|
14
|
+
# long turn shows check-in's card well before it; this catches the rest.
|
|
15
|
+
DEFAULT = 300
|
|
15
16
|
# A --no-interrupt / --non-interactive turn's, at least: nobody is there
|
|
16
17
|
# to answer a continue offer.
|
|
17
18
|
NO_INTERRUPT = 1000
|