samagotchi 0.141.0 → 0.142.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +1 -1
- data/bin/chi +2 -0
- data/docs/architecture.md +12 -283
- data/docs/bundle-authoring.md +1 -0
- data/docs/cli.md +15 -7
- data/docs/configuration.md +57 -18
- data/docs/context.md +2 -2
- data/docs/guardrails.md +20 -11
- data/docs/hooks.md +9 -7
- data/docs/internals/attached-context.md +226 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +380 -0
- data/docs/internals/bundles.md +1 -0
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +24 -18
- data/docs/internals/participants.md +13 -12
- data/docs/internals/remote-sessions.md +1 -1
- data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
- data/docs/memory.md +31 -1
- data/docs/plugins.md +122 -1
- data/docs/sessions.md +50 -26
- data/docs/system-one.md +122 -0
- data/docs/testing.md +3 -1
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
- data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +74 -14
- data/lib/samagotchi/context_candidate_choice.rb +8 -0
- data/lib/samagotchi/context_candidates_log.rb +25 -1
- data/lib/samagotchi/context_judge.rb +6 -2
- data/lib/samagotchi/engine.rb +68 -7
- data/lib/samagotchi/extension_load.rb +3 -2
- data/lib/samagotchi/guardrail_wiring.rb +36 -31
- data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
- data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
- data/lib/samagotchi/guardrails_command.rb +3 -3
- data/lib/samagotchi/judgments_command.rb +155 -0
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/llm/chat_loop.rb +74 -10
- data/lib/samagotchi/llm/errors.rb +7 -5
- data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
- data/lib/samagotchi/plugin/api.rb +33 -2
- data/lib/samagotchi/plugin/context.rb +49 -1
- data/lib/samagotchi/plugin/judgments.rb +72 -0
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +36 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +3 -0
- data/lib/samagotchi/session_manager.rb +13 -2
- data/lib/samagotchi/session_retention.rb +10 -0
- data/lib/samagotchi/side_judgments.rb +12 -1
- data/lib/samagotchi/system_one/answer.rb +63 -11
- data/lib/samagotchi/system_one/approval_label.rb +56 -0
- data/lib/samagotchi/system_one/approval_question.rb +2 -0
- data/lib/samagotchi/system_one/client.rb +43 -17
- data/lib/samagotchi/system_one/fake_backend.rb +1 -1
- data/lib/samagotchi/system_one/http_backend.rb +12 -4
- data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
- data/lib/samagotchi/system_one/judgments.rb +260 -0
- data/lib/samagotchi/system_one/log.rb +4 -2
- data/lib/samagotchi/system_one/recording.rb +117 -0
- data/lib/samagotchi/system_one/runs.rb +179 -0
- data/lib/samagotchi/system_one/runs_store.rb +230 -0
- data/lib/samagotchi/system_one.rb +9 -0
- data/lib/samagotchi/testing/bundle.rb +9 -2
- data/lib/samagotchi/testing/host.rb +2 -1
- data/lib/samagotchi/testing/judgments.rb +185 -0
- data/lib/samagotchi/testing/recorder.rb +5 -0
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tool_output_cut.rb +26 -6
- data/lib/samagotchi/tool_response.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +63 -4
- data/lib/samagotchi/toolkit_suggest.rb +11 -2
- data/lib/samagotchi/tools/memory.rb +28 -0
- data/lib/samagotchi/turn_note.rb +9 -0
- data/lib/samagotchi/version.rb +1 -1
- metadata +16 -2
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# The Bridge
|
|
2
|
+
|
|
3
|
+
The Bridge (`lib/samagotchi/bridge.rb`, `Samagotchi::Bridge`) is a session worker's only live client transport: a
|
|
4
|
+
localhost HTTP server in the worker's process, next to the `Engine` it reads and drives. chi web and the attached
|
|
5
|
+
terminal are its clients; no browser reaches it. [architecture.md](../architecture.md) places it among the layers;
|
|
6
|
+
this page is the surface: what it keeps, how a client joins and catches up, and every route.
|
|
7
|
+
|
|
8
|
+
## What a Bridge keeps
|
|
9
|
+
|
|
10
|
+
`Bridge#start` binds a port (OS-assigned, `127.0.0.1`), then subscribes four persistent observers to the Engine
|
|
11
|
+
(`Engine#subscribe`, the thread-safe fan-out whose events all carry a monotonic `event_seq`). Each is cheap and
|
|
12
|
+
rescues its own errors: an observer must never break the running turn.
|
|
13
|
+
|
|
14
|
+
| Piece | File (`bridge/`) | Holds |
|
|
15
|
+
|-------|------------------|-------|
|
|
16
|
+
| `RingBuffer` | `ring_buffer.rb` | The last `ring_capacity` (256) raw events by `event_seq`, in memory, for a reconnect's replay. The capture observer feeds it. |
|
|
17
|
+
| `TurnAccumulator` | `turn_accumulator.rb` | The turn in progress, folded from events. |
|
|
18
|
+
| `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`. |
|
|
19
|
+
| `PendingCard` | `pending_card.rb` | `pending_card.json` `{id:, bundle:}`: the running turn's open card with actions (check-in's Nudge), written and removed with that card, so chi web's hub, which watches files and not workers, can notify. |
|
|
20
|
+
|
|
21
|
+
Per connection there is an `SSEWriter` (with a `BoundedQueue`); `ClientEvent` and `EventId` are pure helpers. The
|
|
22
|
+
Bridge also owns its **epoch** (`SecureRandom.hex(4)`: `event_seq` starts over in each worker, so the epoch says
|
|
23
|
+
whose seq a cursor is) and its **secret**; both are published in `bridge.json` (`WorkerSidecar`) with the port.
|
|
24
|
+
|
|
25
|
+
## TurnAccumulator
|
|
26
|
+
|
|
27
|
+
A joining UI must draw a turn already half done, and the ring may have dropped its early events. The accumulator
|
|
28
|
+
folds the event log into one hash, `current_turn`: the prompt, `turn_id`, origin, `at`, and ordered `parts`
|
|
29
|
+
(generation, thinking, text, tool, input, steer, reminder, notice), plus `pending_question`. A tool part keeps its
|
|
30
|
+
row fields (`ToolRowFields`), its output cut head-and-tail at `ToolOutputCut::WEB_PREVIEW_CHARS` so a joined row is the
|
|
31
|
+
row a reload draws, and `elapsed_ms` while it runs (the accumulator times running calls itself, `clock:`). A
|
|
32
|
+
`notice` part holds the event of a row that is neither text nor a tool call (`TurnNotice::FIELDS`: a hook's notice,
|
|
33
|
+
the "asking again" row, a steer's cut, a ✂ row, a question and its answer), so a join replays it through the handler
|
|
34
|
+
that drew it live.
|
|
35
|
+
|
|
36
|
+
The turn is cleared on its terminal event (`Events::TURN_END`), the same event that brings its messages into the
|
|
37
|
+
session, so a snapshot shows a turn in exactly one of the two places. Beside the turn it keeps what a snapshot also
|
|
38
|
+
needs and the messages lag: `queued` turns (`:turn_enqueued` until started, merged or dequeued), `queued_commands`,
|
|
39
|
+
the idle `recap` (until a turn starts), a pending `continue_offer`, and the running turn's `working_set`.
|
|
40
|
+
|
|
41
|
+
Three other users: `replay_events(turn)` turns a `current_turn` back into the live events that would have drawn it
|
|
42
|
+
(the web's `snapshotEvents` replays them; `spec/shared/turn_snapshot.json` pins both sides); `messages_of` gives a
|
|
43
|
+
plugin's `ctx.messages` the turn so far (`Engine#running_turn_messages`); `participant_turn?` tells `POST /turn` that
|
|
44
|
+
a participant's review turn runs, so the owner's input queues behind it.
|
|
45
|
+
|
|
46
|
+
## CardStore
|
|
47
|
+
|
|
48
|
+
The cards a plugin or the Engine shows (`Engine#show_card`), hook notices, load warnings (`:guardrail_warning`),
|
|
49
|
+
the "asking again" and steer-cut rows, ✂ rows and questions (with how they were answered), for a UI that joins later:
|
|
50
|
+
`snapshot[:cards]` and `GET /tail`'s `cards`.
|
|
51
|
+
|
|
52
|
+
- **Placement is relative.** An entry carries `turns` and lists with `turns_since` (turns completed after it) and
|
|
53
|
+
`current` (a card of the running turn). A card that comes mid-turn but isn't the turn's (an anytime command's, such as
|
|
54
|
+
`/btw`) goes after that turn (`during`). A card with an earlier card's id replaces it in place.
|
|
55
|
+
- **Caps.** `CAPACITY` (20) each for notices, ✂ rows (`LLM_CONTEXT_TYPES`) and cards, so one burst cannot push out
|
|
56
|
+
another kind. An open question (an approval too) is never pushed out.
|
|
57
|
+
- **Saved.** With a `path` (`cards.json` in the session's folder) the store rewrites the file after each event in
|
|
58
|
+
`SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir)` is
|
|
59
|
+
what chi web reads for a session no worker runs. Load warnings are the worker's own (each start announces them)
|
|
60
|
+
and an unanswered question waits for no one: neither is saved or seeded. An entry from an earlier worker lists with
|
|
61
|
+
`earlier: true`; one shown during a turn comes back **without its actions** (their turn no longer runs), while a
|
|
62
|
+
between-turns card keeps its buttons.
|
|
63
|
+
|
|
64
|
+
## SSEWriter
|
|
65
|
+
|
|
66
|
+
One per `GET /session/:id/stream` connection, and itself that connection's observer: `Engine#emit_event` calls
|
|
67
|
+
`#call` on the turn's thread, so it only pushes onto a `BoundedQueue` (when full, the oldest entry goes and an
|
|
68
|
+
overflow flag is set). The connection's own thread (`#serve!`) drains the queue onto the socket, so a slow client
|
|
69
|
+
cannot stall a turn or another subscriber. `serve!` orders a join so that nothing is missed or doubled:
|
|
70
|
+
|
|
71
|
+
1. subscribe the live queue first;
|
|
72
|
+
2. capture `event_count` (the snapshot seq);
|
|
73
|
+
3. replay the ring's events in `(from_seq, snapshot]`;
|
|
74
|
+
4. drain the queue for `seq > snapshot`; a per-connection high-water mark drops what step 3 already wrote.
|
|
75
|
+
|
|
76
|
+
`?snapshot=1` replaces 2-3: subscribe and `Bridge#snapshot_frame` happen as one step with the event log held
|
|
77
|
+
(`Engine#synchronize_events`), the client gets one `snapshot` frame, and live events follow from the next seq.
|
|
78
|
+
|
|
79
|
+
A **`reset`** frame (the same content, numbered with its own seq) is written when a cursor cannot be replayed: it is
|
|
80
|
+
from another epoch, ahead of the engine (a restarted worker), behind the ring's window (a cursor at `oldest - 1` has
|
|
81
|
+
seen everything before the window and replays), or the queue overflowed since the last drain. A cold connect replays
|
|
82
|
+
the whole window. Replay state is in memory per worker; there is no durable resume.
|
|
83
|
+
|
|
84
|
+
Each frame is `id: <seq>-<epoch>` (`EventId`: the seq first so a reader that takes it as a number still gets the
|
|
85
|
+
seq), `event: <type>`, `data: <json>`. A `: ping` goes out after `heartbeat_interval` (15 s) of silence. A watcher
|
|
86
|
+
thread reads the socket for EOF so a hang-up is noticed at once, not at the next write, and `Bridge#open_streams_except`
|
|
87
|
+
(used by `POST /exit`) stops counting that stream.
|
|
88
|
+
|
|
89
|
+
**ClientEvent.** Every event is projected on its way out, live and replayed alike (`ClientEvent.project`, pure: the
|
|
90
|
+
event hash is shared by every observer and is never mutated). `generation_chunk` loses `payload` and `content` and
|
|
91
|
+
gains `counts`; `tool_call_started` loses `call`; `turn_completed` loses `result`; everything else, `event_seq`
|
|
92
|
+
included, passes through. `?raw=1` skips the projection (debugging). Add a field to the wire only here, when a client
|
|
93
|
+
needs it.
|
|
94
|
+
|
|
95
|
+
## Routes and reads
|
|
96
|
+
|
|
97
|
+
The route list, the wire shapes, the browser/proxy refusals and the secret, as the Bridge answers them. Every route
|
|
98
|
+
goes through `Bridge#dispatch`.
|
|
99
|
+
|
|
100
|
+
The Bridge exposes:
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
- `GET /session/:id/stream` — SSE stream of engine + kernel events, each with an
|
|
104
|
+
`id: <event_seq>-<epoch>` cursor (the epoch is drawn per worker's Bridge, since `event_seq` starts
|
|
105
|
+
over in each worker; snapshots and `/state` carry it as `event_id`); resume via `Last-Event-ID` /
|
|
106
|
+
`?from_seq=` (a plain `event_seq` is still accepted); a `: ping` heartbeat keeps idle proxies alive;
|
|
107
|
+
too-old reconnects, and cursors from another worker's epoch, receive a `reset` marker carrying
|
|
108
|
+
`session_state_snapshot`. `?snapshot=1` joins with a snapshot frame instead of a replay;
|
|
109
|
+
`?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
|
|
110
|
+
Each event goes out as clients get it (`Bridge::ClientEvent.project`, in `SSEWriter#write_event`, live
|
|
111
|
+
and replayed alike); `generation_chunk`, `tool_call_started` and `turn_completed` are projected to the
|
|
112
|
+
contracts below. The ring retains raw events for replay and `?raw=1`.
|
|
113
|
+
`?raw=1` writes events as the engine emitted them, for debugging on the worker's port: `chi web`'s
|
|
114
|
+
`/api/sessions/:id/stream` never passes it on (`Web::App#stream_query`).
|
|
115
|
+
An attached terminal's stream (`EventStream` with `rediscover:`) looks for the session's live
|
|
116
|
+
worker whenever it drops: a new one (a restart) is followed from its snapshot, and the terminal
|
|
117
|
+
sends its requests there (`worker_changed`); it gives up after ~12 s, or at once when the session
|
|
118
|
+
was stopped.
|
|
119
|
+
|
|
120
|
+
The wire chunk (the contract; the engine's own event keeps the provider's `payload` and the raw `content`):
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
event: generation_chunk
|
|
124
|
+
data: {"type":"generation_chunk","iteration":2,"text":"…","thinking":"",
|
|
125
|
+
"tool_call":true, // only when set
|
|
126
|
+
"counts":{"prompt_tokens":489,"completion_tokens":12,
|
|
127
|
+
"total_tokens":501,"context_window_tokens":32768}, // only when the server reported counts
|
|
128
|
+
"event_seq":812}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A `tool_call_started` frame keeps its row fields but omits the model's `call`:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
event: tool_call_started
|
|
135
|
+
data: {"type":"tool_call_started","iteration":2,"call_index":1,"tool":"write",
|
|
136
|
+
"params":"path=notes.txt","event_seq":812}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A `turn_completed` frame omits `result`; `turn_summary.output` carries the answer:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
event: turn_completed
|
|
143
|
+
data: {"type":"turn_completed","turn_summary":{"output":"Done."},
|
|
144
|
+
"display_pending":false,"event_seq":813}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`counts` is `ContextUsage.normalize` of the provider's payload without `ctx_pct` (normalize computes it
|
|
148
|
+
without the session's LLM context budget; a client counts `total_tokens` against the window or budget, as
|
|
149
|
+
`ContextStatus#generation_done` does for the TUI) and with its nil keys left out: cumulative for the
|
|
150
|
+
request, a missing key means unknown (never 0), `prompt_tokens` includes cached tokens, and
|
|
151
|
+
`context_window_tokens` is there only when the payload reported a window itself (llama.cpp's `/completion`
|
|
152
|
+
doesn't: the client uses `generation_started`'s). Unknown provider fields never cross; a field is added here
|
|
153
|
+
when a client needs it, never as a catch-all bag. Details: [internals/context-telemetry.md](context-telemetry.md).
|
|
154
|
+
- `POST /session/:id/turn` — fire-and-forget turn creation; returns `202` with an `enqueued_id`
|
|
155
|
+
(delivery is at-least-once via the worker's file-IPC input path — it never calls `run_turn`
|
|
156
|
+
across the HTTP boundary). Only the bridge's own session: another id, in the path or the body's
|
|
157
|
+
`session_id`, is `404 unknown_session`. Inspect results through the read surface, not the turn response.
|
|
158
|
+
An optional `deadline` (epoch seconds; `BridgeClient` sends 5/6 of its read timeout ahead) makes
|
|
159
|
+
a request read after it (a worker frozen by sleep or SIGSTOP) answer `408 deadline_passed` and
|
|
160
|
+
not run: a client that timed out has said the message was not sent. `/answer`,
|
|
161
|
+
`/question/dismiss`, `/command`, `/turn/dequeue` and `/exit` take the same `deadline` (a command is
|
|
162
|
+
checked with the event log held, as a turn is), and the Bridge logs `turn_expired`, `answer_expired`,
|
|
163
|
+
`dismiss_expired`, `command_expired`, `dequeue_expired` or `exit_expired`. The web app answers either
|
|
164
|
+
kind of timeout with `504 worker_timeout` ("… so the command was not run"). `/cancel` and `/recap` take none.
|
|
165
|
+
The web relays answer, dismiss, command and cancel the same way (`App#relay`): a 408 or a read
|
|
166
|
+
timeout is `504 worker_timeout`; a Bridge 404 for a route an older worker lacks is
|
|
167
|
+
`501 not_supported` with the restart hint; a Bridge `404 unknown_session`, no bridge or a
|
|
168
|
+
refused connection is `503 not_live`.
|
|
169
|
+
- `POST /session/:id/cancel` — cancel the running turn; `202`, or `409` when none runs.
|
|
170
|
+
- `POST /session/:id/recap` — `/recap` in an attached TUI: `200 {enabled, saved, request,
|
|
171
|
+
min_user_turns}` (`{enabled: false, session_off}` without recaps; `session_off` when the session's own
|
|
172
|
+
`/recap off` turned it off); a new recap is asked for at once and
|
|
173
|
+
arrives as `:recap_ready`. Answers mid-turn too.
|
|
174
|
+
- `POST /session/:id/answer` — answer the pending question; `200`, `409` when another client
|
|
175
|
+
answered first or it is gone, `400` for an invalid selection. With `client_id: "cli:answer"`
|
|
176
|
+
(`chi answer`, a parent agent) the answer is checked by `Guardrails::ParentPolicy` in
|
|
177
|
+
`QuestionDesk#answer`: the question carries each option's effect (`effects`, set by its asker:
|
|
178
|
+
`Approval.payload`, `ContinueOffer`, `Relay.card`; derived for an untagged one) and `user_only`,
|
|
179
|
+
and an option beyond the worker's `guardrails.parent_answers` (tightened by the session's own
|
|
180
|
+
`parent_answers`: `ParentPolicy.for_session`) is `403 parent_answer_refused` (with its `reason`),
|
|
181
|
+
the question still open. `chi answer` makes the same check first, from the session file.
|
|
182
|
+
- `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
|
|
183
|
+
`200`, or `409` when it is no longer pending.
|
|
184
|
+
- `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
|
|
185
|
+
(`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
|
|
186
|
+
web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
|
|
187
|
+
`task_create` started, or an `execute` handed over as a task at its limit (`TaskRuntime.created_ids_in` on the
|
|
188
|
+
saved messages, or the running turn's accumulator); answers once the
|
|
189
|
+
task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
|
|
190
|
+
The worker names it in its sidecar features (`task_stop`).
|
|
191
|
+
- `POST /session/:id/turn/dequeue` — `{enqueued_id, client_id?, deadline?}`: take back a queued message
|
|
192
|
+
(one that waits for the running turn's end: `--queue`, Alt+Enter, `/queue TEXT`, or a message
|
|
193
|
+
with images) before a turn takes it (web route `POST /api/sessions/:id/turn/:enqueued_id/dequeue`,
|
|
194
|
+
the × on a queued bubble; `/unqueue` in the attached TUI). Under the event log, like its enqueue:
|
|
195
|
+
`SessionInbox.take_queued_input` writes a tombstone (`input/removed/<enqueued_id>`, for `ReplyWait`'s
|
|
196
|
+
`turn:`), claims the file as the drain does (one rename wins; a lost claim takes the tombstone back),
|
|
197
|
+
deletes it, and the Bridge announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
|
|
198
|
+
(a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
|
|
199
|
+
unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
|
|
200
|
+
A worker's start prunes the `input/removed/` and `input/settled/` tombstones older than a week
|
|
201
|
+
(`SessionInbox::TOMBSTONE_MAX_AGE`, log `tombstones_pruned`); otherwise they go with the session dir.
|
|
202
|
+
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
203
|
+
(`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
|
|
204
|
+
pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
|
|
205
|
+
makes the child ask its parent's Bridge for the answer (`RelayVerifier`) and take it only for
|
|
206
|
+
this child and the question pending now. `200`, `409` no longer pending, `403` a parent agent's
|
|
207
|
+
pick beyond the child's own `guardrails.parent_answers` and session `parent_answers` (`parent_answer_refused`; the
|
|
208
|
+
relay card in the parent was held to the parent's), `422 relay_unverified` (the question
|
|
209
|
+
stays open).
|
|
210
|
+
- `POST /session/:id/relay/status` — on a parent's worker, `{relay_id}`: what its `RelayDesk`
|
|
211
|
+
(memory only) holds for that relay (`child_id`, `child_question_id`, `state`, `answer`, `by`);
|
|
212
|
+
`404` for an unknown id. A POST with a body because routes match exact paths.
|
|
213
|
+
- `POST /session/:id/command` — a session command (`/model`, `/models`, `!rollback`, `!cmd`,
|
|
214
|
+
`/continue`) for the worker loop; `202` with a `command_id` its `:command_ran` names, `400` when
|
|
215
|
+
the line isn't one. A `:command_ran` carries `thinking`, `reasoning_history` and `llm_context`
|
|
216
|
+
(the summaries the next turn runs under) when the command changed them or the model, and
|
|
217
|
+
`confirm: {line, text}` when a `/thinking` or `/reasoning-history` change from a `web:` or `tui:`
|
|
218
|
+
client asks first (`CacheConfirm`; nothing changed, `line` applies it).
|
|
219
|
+
- `POST /session/:id/exit` — ask the worker to exit now (`{client_id:, delete?, restart?, deadline?}`; `delete`:
|
|
220
|
+
`/exit --delete`, no recap written; `restart: true`: hand the session to a new worker on the newest chi
|
|
221
|
+
installed, `200 {status: "restarting"}`; an empty session's `exiting` carries `discard: true`). The worker checks with
|
|
222
|
+
the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
|
|
223
|
+
like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
|
|
224
|
+
`continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
|
|
225
|
+
`background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches), `starting`, or
|
|
226
|
+
`leaving_exit` / `leaving_delete` / `leaving_restart` when another client's exit of another kind is already taken
|
|
227
|
+
(`Worker#claim_exit`, claimed before a stop-all; the words are `WorkerIdleExit::LEAVING_WORDS`). A second exit of
|
|
228
|
+
the kind already taken is answered `200` as that one.
|
|
229
|
+
- `GET /session/:id/state` — `session_state_snapshot` (JSON).
|
|
230
|
+
- `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
|
|
231
|
+
- `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
|
|
232
|
+
the messages itself, then streams from its `event_seq`). Read by the web's full show
|
|
233
|
+
(`GET /api/sessions/:id`, `?parts=1`) and the plugin's `list_sessions`; every message is copied.
|
|
234
|
+
- `GET /session/:id/tail` — the page's light re-read (`Bridge#tail_frame`): `session_state_snapshot`,
|
|
235
|
+
`working_directory` (the session's folder, which the web checks the answer's file refs against),
|
|
236
|
+
`answer` (the last message a UI shows as an answer, `AnswerTail`: the one message, copied alone),
|
|
237
|
+
`cards` (`CardStore#list`; a card an earlier worker showed during a turn comes back read-only, with no
|
|
238
|
+
actions: its turn no longer runs), `event_seq`, `event_id`, all taken in one event-log hold; no message
|
|
239
|
+
list, so it costs the same however long the session is. `?turn_id=` answers that turn's answer
|
|
240
|
+
(an unknown or missing id: the newest); the only route that reads its query.
|
|
241
|
+
- No browser: only chi's own clients talk to a Bridge. A request with an `Origin` header, a `Sec-Fetch-Site`
|
|
242
|
+
other than `none`, or a `Host` that isn't loopback (a DNS-rebound page) is `403 cross_origin`
|
|
243
|
+
(`Bridge#browser_request?`, before any route); there is no CORS and no `OPTIONS` route. The browser reaches a
|
|
244
|
+
worker only through `chi web`. A request carrying any trace a proxy adds (`ProxyTrace`: `X-Forwarded-*`,
|
|
245
|
+
`Forwarded`, `X-Real-IP`, `X-Auth-Request-*`, `Cf-*`…, underscore twins too) is `403 proxied`
|
|
246
|
+
(`Bridge#proxied_request?`): an auth proxy that forwards every local port and adds such a header doesn't reach a
|
|
247
|
+
worker.
|
|
248
|
+
- **The secret.** Every request, on every route (the stream, the reads such as `state` and `stats`, the writes, and
|
|
249
|
+
an unknown path), names the Bridge's secret in `X-Chi-Bridge`; one without it, or with another one, is
|
|
250
|
+
`403 bridge_secret` with one session-specific message (`BridgeSecret.refused`): "session ID's worker refused
|
|
251
|
+
this chi's request (it didn't name its secret): a chi web or terminal started before chi was updated can't
|
|
252
|
+
reach it; restart chi web, re-attach with `chi --attach ID`, or restart the worker with `chi sessions restart
|
|
253
|
+
ID`." `chi sessions restart` can also recover when the worker cannot be asked: TERM + resume if disk shows no
|
|
254
|
+
running turn, pending question or approval, queued input, or running background task. The check is after the
|
|
255
|
+
browser and proxy checks and before any route (`Bridge#secret_ok?`,
|
|
256
|
+
`OpenSSL.secure_compare`). No route is left open: what the reads show is the session itself. Only a request that
|
|
257
|
+
got past it counts as client activity, so a refused one (an older chi web's polls) never holds the idle exit. Each Bridge makes its own (`SecureRandom.hex(32)`, 64 hex
|
|
258
|
+
characters) when it starts and writes it to `bridge.json`, which is 0600 from the moment it exists
|
|
259
|
+
(`AtomicFile` creates the temporary file with that mode, then renames it), so only this machine's user can read
|
|
260
|
+
it: a forward that adds no proxy header, or another local account, reaches the port but not a session. Clients get
|
|
261
|
+
it only from the sidecar: `WorkerLink.client`/`.await` and `BridgeClient.for_sidecar` take the port and the secret from the
|
|
262
|
+
same read (chi web makes its client from the sidecar for every request, the attached terminal again on a
|
|
263
|
+
`worker_changed`), so a respawned worker's new one is used and nothing passes a port by hand. A worker from before
|
|
264
|
+
the secret writes none and asks for none; a chi web or attached terminal from before it sends none, so a worker
|
|
265
|
+
the newer chi started refuses it until it is restarted. A client tells that refusal from a route's own 403
|
|
266
|
+
(`BridgeClient::Response#secret_refused?`) and shows the one message: chi web's relays answer 502 `bridge_secret`,
|
|
267
|
+
`chi answer` and a parent's approval relay say it instead of "refused", and an event stream ends with reason
|
|
268
|
+
`bridge_secret` once rediscovery finds no newer worker (the attached terminal's closing line), never retrying in
|
|
269
|
+
silence; chi web's stream proxy answers 502 `bridge_secret`, never passing the refusal on as frames. The secret is never logged (a refusal logs
|
|
270
|
+
`secret_refused` with the path, at most once a minute), never in an event, a reply, `/api/info` or a session
|
|
271
|
+
file, and `WorkerSidecar`, `BridgeClient` and `Bridge` leave it out of `#inspect`.
|
|
272
|
+
|
|
273
|
+
The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
|
|
274
|
+
(`SessionMetrics#snapshot`) carry the session's totals and only the recent timing records: the
|
|
275
|
+
newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
|
|
276
|
+
calls and the running turn's finished ones. The full history is the session's `analytics.json`,
|
|
277
|
+
which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
|
|
278
|
+
The totals' `tokens` block adds the cached, cache-write and reasoning sums, the reported cost (`cost_sum`) and the
|
|
279
|
+
estimated one (`cost_estimate_sum`, from `hosts.<name>.models` prices; 0 for none), the decode time and tokens
|
|
280
|
+
behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
|
|
281
|
+
`last_prefill_tps`, `tps_source`: `server` or `estimate`). `model_segments` (always present) splits those sums
|
|
282
|
+
by model after a `/model` switch: one row per run of consecutive turns on one model (`SessionMetrics::ModelSegment`,
|
|
283
|
+
keyed on the turn records' `model_ref`), in order, with its turns, token and cost sums, speeds and the switch's prefill
|
|
284
|
+
(`prefill_tokens`, `prefill_cost_estimate`: the sums of its turn records' `switch_prefill_tokens` and
|
|
285
|
+
`switch_prefill_cost_estimate`, [sessions.md](../sessions.md)); `[]` with one model, the oldest rows folded into one (`folded_segments`)
|
|
286
|
+
past 6; a running turn counts in its model's row (`ended_at` null). The rows add up to `tokens`. They are derived from
|
|
287
|
+
the records, never read back. Each `generation_completed` event
|
|
288
|
+
carries the generation's `speed` (`{decode_tps, source}`, null without one), its own prompt-cache
|
|
289
|
+
counts (`prompt_tokens`, `cached_tokens`, `cache_write_tokens`: null when the server reported no writes) and
|
|
290
|
+
those running `tokens` and `model_segments`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
|
|
291
|
+
it, so the web's info bar updates per generation without parsing chunk payloads.
|
|
292
|
+
Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are counted apart, in a `side` block
|
|
293
|
+
(only when there were any; `SessionMetrics::SideTotals`: requests, token sums, `cost_sum` null until reported,
|
|
294
|
+
`cost_estimate_sum` from the target host's price, `by_kind`): `IdleClient` hands each reply's usage to its
|
|
295
|
+
`on_usage:` callback, `Engine#note_side_usage`, which folds it (`SessionMetrics#add_side`) and announces
|
|
296
|
+
`:side_usage` (`kind`, the whole `side` block) under the event lock, so the blocks arrive in fold order, then saves
|
|
297
|
+
`analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
|
|
298
|
+
stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
|
|
299
|
+
threads' saves land in snapshot order.
|
|
300
|
+
A bundle's `ctx.cost` reads the same sums (`SessionMetrics#cost`, a frozen `SessionMetrics::Cost`): the `tokens`
|
|
301
|
+
block's `cost_sum` and `cost_estimate_sum` with the running turn's finished generations folded in, plus the `side`
|
|
302
|
+
block's, so it counts what earlier workers spent too ([plugins.md](../plugins.md)). Its `ctx.usage` reads the work
|
|
303
|
+
(`SessionMetrics#usage`, a frozen `SessionMetrics::Usage`): the snapshot's `turns`, `tool_calls_total` /
|
|
304
|
+
`tool_calls_by_tool` and `iterations_total`, and `active_ms`, the generations' `gen_ms` plus the finished tool
|
|
305
|
+
records' `duration_ms` (tallied per tool as they are counted; `ask_user_question`'s, a wait on the user, left out).
|
|
306
|
+
|
|
307
|
+
The page's frequent reads of `GET /api/sessions/:id` stay light too:
|
|
308
|
+
- `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
|
|
309
|
+
used_memory_names}, messages: [the answer, with its display and display_refs (re-read on an `answer_display` with a display or refs)], markdown_warning, timing}`, from the worker's `/tail`
|
|
310
|
+
alone (no session file parsed, no `analytics.json`); `timing` carries the worker's recent records
|
|
311
|
+
and `turn_count` (finished turns). The page merges the records by id (`timing.js mergeTiming`) and
|
|
312
|
+
finishes the ended turn's own line by its `turn_id` (sent as `&turn_id=`, which also picks that
|
|
313
|
+
turn's answer). A tail read's timing is always the newest turn's; `recent=1` is accepted and not needed.
|
|
314
|
+
The page binds an answer's `display_refs` to its bubble (`answer_refs.js`: on a reload's render and on this
|
|
315
|
+
read's re-render) and pairs them with the bubble's links and text before the `path:line` scan. A question's
|
|
316
|
+
refs (`pending_question.refs`, `:question_asked`) pair the same way (`markdown_place.js` `linkRefsIn`/`pairRefsIn`):
|
|
317
|
+
`question_requested` (or a snapshot's pending question) keeps them on the asking step's gen
|
|
318
|
+
(`turn_model.js` `questionRefs`) before the card draws, so the stage's asking slot (`stage_slots.js`
|
|
319
|
+
`drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
|
|
320
|
+
question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
|
|
321
|
+
(`turn_block.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
|
|
322
|
+
the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
|
|
323
|
+
from its message's `html` (`turn_block.js` `textTemplate`), a live turn's from `POST /api/render` on its step's
|
|
324
|
+
first open after the turn's end (`markdown_place.js` `renderStepTexts`, which pairs its refs again after the
|
|
325
|
+
swap); the text as written stays on it as `data-copy-source` (`stepText`, a reloaded card's kept reply). A file ref outside the
|
|
326
|
+
session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
|
|
327
|
+
session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
|
|
328
|
+
file ref is dropped rather than shown unchecked.
|
|
329
|
+
A plugin tool's row refs (`refs`, `RowRefs`) ride its tool events and, on a reload, its part (saved as
|
|
330
|
+
`tool_refs`); `turn_block.js` binds them to the row's title (`refs.js bindRowRefs`: the first on the title,
|
|
331
|
+
the others in its menu).
|
|
332
|
+
- `?timing=1`: the whole timing (`analytics.json` merged with the worker's `/state`) and
|
|
333
|
+
`turn_count`, when the page's merge came up short.
|
|
334
|
+
- `?cards=1`: the cards, from `/tail`; the session file is only stat'ed for the 404.
|
|
335
|
+
|
|
336
|
+
The stage view's working set (`web.working_set`) has its own read, `GET /api/sessions/:id/working_set`: the
|
|
337
|
+
`WorkingSet` projection (`{items, at, running}`: each file, memory and URL live, stale or forgotten, with its tokens)
|
|
338
|
+
of the worker's `/snapshot` messages, else the session file, under the session's LLM context strategy; while a turn
|
|
339
|
+
runs, the snapshot's `working_set` (the running turn's last event, which the messages lag) when it has one. Its live form
|
|
340
|
+
is the `:working_set_updated` event, which always wins over a GET's answer it overtook. The page overlays it on the
|
|
341
|
+
set it builds from its tool rows ([internals/working-set.md](working-set.md)).
|
|
342
|
+
|
|
343
|
+
A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
|
|
344
|
+
shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
|
|
345
|
+
turn's records.
|
|
346
|
+
|
|
347
|
+
Every route goes through `Bridge#dispatch`: an id other than the bridge's own session is
|
|
348
|
+
`404 unknown_session`, and a handler that raises answers `500 bridge_error` (logged as
|
|
349
|
+
`handler_failed`) rather than dropping the connection.
|
|
350
|
+
|
|
351
|
+
The per-session port is OS-assigned (bound to `0`) and published to a `bridge.json` sidecar
|
|
352
|
+
for client discovery, with the Bridge's secret (see above).
|
|
353
|
+
|
|
354
|
+
**Worker link.** Every client asks `WorkerLink` (`worker_link.rb`) whether a session's worker can be reached, never
|
|
355
|
+
reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
|
|
356
|
+
outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
|
|
357
|
+
session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
|
|
358
|
+
sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read and the lock,
|
|
359
|
+
read again just before, is still free: a worker that took it meanwhile is resolved as that worker), else `None`;
|
|
360
|
+
a holder with no kind yet: `Starting`; a leaving worker: `Leaving` (marked before its Bridge stops, on every way out:
|
|
361
|
+
`Worker#teardown` marks it too, with `TEARDOWN_LEAVE_WAIT`); a worker whose sidecar names no port or another
|
|
362
|
+
pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
|
|
363
|
+
past `START_LIMIT` (30 s) with none; a sidecar whose `protocol` is newer than `WorkerLink::PROTOCOL`: `Skew` (a
|
|
364
|
+
newer worker; never driven, `wake` answers 409 `worker_newer`); else `Live` (its sidecar; `#client` makes the one
|
|
365
|
+
`BridgeClient`). A sidecar with no `pid` is an older worker's: the lock alone says it is live. `owner:` is the
|
|
366
|
+
holder as the caller already read it (nil: free), so one read serves a summary and its status. `WorkerLink.client` is
|
|
367
|
+
the Live's client or nil; `WorkerLink.await` polls for a new worker's Live (an `until:` check tells a restart's
|
|
368
|
+
successor from the old worker); chi web's `App#worker_link` / `#await_worker_link` wrap them. The Bridge writes
|
|
369
|
+
`pid` and `protocol` with its port and removes its sidecar on stop only when the file still carries its epoch.
|
|
370
|
+
|
|
371
|
+
`chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
|
|
372
|
+
(503 `not_live` for no worker, a leaving or stuck worker, or an unreachable Bridge; 502 `worker_newer` for skew and
|
|
373
|
+
`bridge_error` / `bridge_secret` for a refused Bridge; 200 only after the Bridge answers; full history of any session
|
|
374
|
+
is served by `GET /api/sessions/:id/output`). It opens the Bridge before answering and gives the one opened socket to
|
|
375
|
+
`App#proxy_stream_body` / `ProxyStreamBody`, which closes it when the body or hijacked client ends. The owner's stream
|
|
376
|
+
is a byte pipe; the body gives it the request's frame filter (`App#stream_filter`: nil for the owner; for a participant, and the owner's participant view
|
|
377
|
+
`?as=participant`, a `Web::ParticipantStreamFilter`, which cuts the Bridge's chunks into frames and writes each again
|
|
378
|
+
from `Web::ParticipantView.event`'s allowlist, dropping every other event; only `from_seq` and `snapshot` reach the
|
|
379
|
+
Bridge; [internals/participants.md](participants.md#what-a-participant-sees)). Resume/ring-buffer state is **in-memory**, per worker: a cursor from
|
|
380
|
+
an earlier worker gets the `reset` snapshot, not a replay.
|
data/docs/internals/bundles.md
CHANGED
|
@@ -43,6 +43,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
|
|
|
43
43
|
| `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
|
|
44
44
|
| `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
|
|
45
45
|
| `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
|
|
46
|
+
| `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
|
|
46
47
|
| `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
|
|
47
48
|
| `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
|
|
48
49
|
|
|
@@ -86,7 +86,7 @@ Configuration:
|
|
|
86
86
|
- `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` / `context.window_tokens`: context window size for when neither the server nor the model's or host's config gives one (step 4 of the lookup below).
|
|
87
87
|
- `models.<key>.window_tokens` / `hosts.<name>.window_tokens` (config file only): the window for one model, or for every model on a host (step 3 below).
|
|
88
88
|
- `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (default `4.0`): heuristic ratio for char-to-token estimation.
|
|
89
|
-
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` / `context.status_thresholds` (default `20,40,60,80`): comma-separated threshold percentages. They set the buckets, and the buckets' guidance gates the model's own `[CONTEXT: …]` line
|
|
89
|
+
- `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` / `context.status_thresholds` (default `20,40,60,80`): comma-separated threshold percentages. They set the buckets, and the buckets' guidance gates the model's own `[CONTEXT: …]` line; the `:context_status` event streams on every request.
|
|
90
90
|
|
|
91
91
|
The `context.*` settings are read once, when the turn's `ContextStatus` is built, so a change counts from the next turn.
|
|
92
92
|
|
data/docs/internals/engine.md
CHANGED
|
@@ -348,12 +348,12 @@ One iteration with tool calls:
|
|
|
348
348
|
- For edit and write: diffs the file before and after, and refreshes a memory's index line
|
|
349
349
|
(`MemoryBundle::IndexSync`).
|
|
350
350
|
- Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
|
|
351
|
-
- Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` by the tool's `ToolOutputCut`
|
|
351
|
+
- Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` (a `memory_read`'s is `memory.read_max_chars`, 0: none, with an overflow guard at half a known window) by the tool's `ToolOutputCut`
|
|
352
352
|
policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
|
|
353
353
|
kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
|
|
354
354
|
and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
|
|
355
355
|
(`ToolOutputFiles`, through `KernelLoop#session_dir`, which the Engine sets). See
|
|
356
|
-
[Tool output guardrails](tool-
|
|
356
|
+
[Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
|
|
357
357
|
event and the after_tool_call hook get that text.
|
|
358
358
|
- Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
|
|
359
359
|
activity, images, diff, waited_ms, view, ref, refs}, then fires `:after_tool_call` {iteration, tool, output,
|
|
@@ -598,9 +598,12 @@ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any autho
|
|
|
598
598
|
`Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
|
|
599
599
|
`SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
|
|
600
600
|
keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
|
|
601
|
-
- **The gate**: `GuardrailWiring#
|
|
602
|
-
`#
|
|
603
|
-
|
|
601
|
+
- **The gate**: `GuardrailWiring#participant_boundary` is the core check after the load failures, picked by
|
|
602
|
+
`#turn_mode`: `Guardrails::PairBoundary` for a pair turn, `Guardrails::ReviewBoundary` for a review one. Both read
|
|
603
|
+
`#turn_author` and are no-ops for the owner's turns; the review boundary denies a participant's turn everything
|
|
604
|
+
outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
|
|
605
|
+
`ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
|
|
606
|
+
The declared tools stay the session's (D3: no rebuild per author).
|
|
604
607
|
- **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise) is the hook
|
|
605
608
|
runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
|
|
606
609
|
plugin host's (`ctx.turn_author`).
|
|
@@ -719,7 +722,7 @@ loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7
|
|
|
719
722
|
model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
|
|
720
723
|
`AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
|
|
721
724
|
(`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
|
|
722
|
-
relayed approval (`
|
|
725
|
+
relayed approval (`Engine::RelayPeer#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
|
|
723
726
|
fire it. `QuestionAsked.fire` is a no-op without a subscriber (`Registry#any?`); else it finds the live
|
|
724
727
|
conversation's last `role: "model"` entry (`Engine#live_conversation`, one lookup for both loops: a ChatLoop batch may
|
|
725
728
|
end in a tool result), builds an `AnswerRefs` on that step alone with the question caps (50 / 32 KiB), fires with the
|
|
@@ -752,16 +755,12 @@ step's text ([architecture.md](../architecture.md), the `display_refs` paragraph
|
|
|
752
755
|
`ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
|
|
753
756
|
the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
|
|
754
757
|
known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
|
|
755
|
-
`event[:blocked]` flag. A deny stays a deny for the hooks after it
|
|
756
|
-
in `GuardrailWiring#checks` order
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
4. `ScratchWrites` (a `chi scratch` session only): write and edit under the memories folder are denied;
|
|
762
|
-
5. `ChildBoundary` (a delegate child only): a change outside its folder asks;
|
|
763
|
-
6. the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match the tool, its
|
|
764
|
-
targets and the model).
|
|
758
|
+
`event[:blocked]` flag. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are
|
|
759
|
+
disabled. Then the core checks run on the final call in `GuardrailWiring#checks` order
|
|
760
|
+
(`lib/samagotchi/guardrail_wiring.rb`): the load failures first, then the participant's boundary
|
|
761
|
+
(`participant_boundary`, `PairBoundary` for a pair turn else `ReviewBoundary`), then the hook asks, the protected
|
|
762
|
+
paths, the scratch writes, the delegate child's boundary, the rules. That method is the order; it is not spelled
|
|
763
|
+
out here so this page can't drift from it.
|
|
765
764
|
|
|
766
765
|
A gate that raises denies the call.
|
|
767
766
|
|
|
@@ -795,6 +794,13 @@ Engine
|
|
|
795
794
|
|
|
796
795
|
Both loops:
|
|
797
796
|
|
|
797
|
+
- <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`, `LLM::ChatLoop` only): a reply with no tool
|
|
798
|
+
calls whose whole text is one closed tool-call block, which the host couldn't read, isn't an answer. A reply opening with prose or a fence stays
|
|
799
|
+
an answer, and the check is one string test on a normal one. Nothing is guessed from the block and it never enters the history (`:generation_completed`
|
|
800
|
+
and `:after_generation` have already seen the text): `:generation_retrying` with `restarted` voids the streamed text, `:empty_answer_retry` with
|
|
801
|
+
`malformed` goes out, and a hidden `TurnNote.malformed_retry` asks again at the retry temperature. It has its own budget, so `retry.empty_answer: 0`
|
|
802
|
+
doesn't turn it off, and a real tool call resets it. Two in a row fail the turn with `LLM::MalformedGeneration`, keeping its earlier steps. Each is
|
|
803
|
+
logged as `generation_malformed` with the shape, finish reason, served model and the text's tail. `KernelLoop` has its own path (`ToolCallParser#malformed`).
|
|
798
804
|
- **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
|
|
799
805
|
again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
|
|
800
806
|
The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
|
|
@@ -884,7 +890,7 @@ Paths are under `lib/samagotchi/`.
|
|
|
884
890
|
| `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
|
|
885
891
|
| `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
|
|
886
892
|
| `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
|
|
887
|
-
| `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-
|
|
893
|
+
| `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` (a `memory_read`'s: `memory.read_max_chars`) is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-output-limits.md](tool-output-limits.md)) |
|
|
888
894
|
| `tool_row_fields.rb`, `row_refs.rb` | `ToolRowFields`: a tool row's fields beyond its params (`ACTIVITY_KEYS`, `EVENT_KEYS`), the same live and on a reload; `RowRefs`: a plugin tool's row refs (`refs:`), checked, capped and saved with the result |
|
|
889
895
|
| `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
|
|
890
896
|
| `empty_answer_retry.rb` | The empty-answer and cut retry budget |
|
|
@@ -914,5 +920,5 @@ Paths are under `lib/samagotchi/`.
|
|
|
914
920
|
| `worker_wakes.rb` | `WorkerWakes`: a worker's wake budget for the turns nobody typed (`session.max_wakes`) |
|
|
915
921
|
| `task_watch.rb`, `task_finish_note.rb` | `TaskWatch`: the background tasks a worker's session started that the model hasn't seen finish; `TaskFinishNote`: the note one leaves when it does ([background-tasks.md](background-tasks.md#finish-wake)) |
|
|
916
922
|
| `side_usage.rb` | `SideUsage`: one side request's usage (a recap, `ctx.ask_model`), folded into the session's side sums (`:side_usage`) |
|
|
917
|
-
| `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([
|
|
923
|
+
| `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([bridge.md](bridge.md)) |
|
|
918
924
|
| `guardrails/parent_policy.rb` | `Guardrails::ParentPolicy`: which options of a question a parent agent may pick (`guardrails.parent_answers`) |
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Participants: identity and routes
|
|
2
2
|
|
|
3
3
|
A session's owner can invite **participants** to review it: people, or agents, who reach that one session through
|
|
4
|
-
chi web with a link of their own. This page is the identity layer
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
experimental. An agent participant waits on its own turns' replies through the API. The
|
|
8
|
-
"participants" and "
|
|
4
|
+
chi web with a link of their own. This page is the identity layer: grants, the gate, the routes a participant
|
|
5
|
+
reaches, and what is written where; and their **turns**: review turns queued, read-only, limited and capped, pair
|
|
6
|
+
turns that act under the owner's guardrails; and what they **see**: their page and its reads, all through one
|
|
7
|
+
filter. Participants are experimental. An agent participant waits on its own turns' replies through the API. The
|
|
8
|
+
code and docs say "participants", "review" and "pair", never "shared": `session.shared` already means "attached by
|
|
9
|
+
default".
|
|
9
10
|
|
|
10
11
|
Participants reach chi web on the **LAN** (`web.host: lan`), or **behind an auth proxy** with `web.public_url`
|
|
11
12
|
(experimental; docs/configuration.md, "Behind an auth proxy"): there every request needs a token, this machine's too,
|
|
@@ -43,9 +44,9 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
43
44
|
newer chi's: read (its links work, but a grant with keys this chi doesn't know is left out: such a key may
|
|
44
45
|
restrict it, so it fails closed) and never written (`Grants::Newer`, one message saying to update chi; a revoke
|
|
45
46
|
the gate can't write still refuses the grant). A grant's keys this chi doesn't know are kept on a rewrite
|
|
46
|
-
(`Grant#extra`), so a field a later chi adds the omit-if-default way survives.
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
(`Grant#extra`), so a field a later chi adds the omit-if-default way survives. **Mixed installs** on one state dir
|
|
48
|
+
are a hazard: an older chi that doesn't know `identity` unbinds every grant when it rewrites the store, and one
|
|
49
|
+
that doesn't keep a grant's unknown keys drops them. A stored `identity` that isn't one
|
|
49
50
|
`#issue` would write (`false`, `""`, a list, upper case) makes the store Corrupt (fails closed). Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
|
|
50
51
|
`last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
|
|
51
52
|
be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
|
|
@@ -648,7 +649,7 @@ invited to has none: its files stay byte for byte as they were.
|
|
|
648
649
|
expires_at, removed_at, cap}]}`, rewritten by whoever changes a grant (chi web, `chi share`), inside the grants
|
|
649
650
|
store's lock (`Grants#update`'s `on_write`), so two writers can't finish out of order and lose a revoke here. Never
|
|
650
651
|
a token or a digest.
|
|
651
|
-
The worker reads it with `Participants.read` (
|
|
652
|
+
The worker reads it with `Participants.read` (`SessionParticipants#refresh!`, for the system prompt's participants line).
|
|
652
653
|
- **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
|
|
653
654
|
(participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
|
|
654
655
|
expiry, once per chi web), `peer` (the first use and each new peer since chi web started: ip, user agent; behind a
|
|
@@ -668,8 +669,8 @@ invited to has none: its files stay byte for byte as they were.
|
|
|
668
669
|
|
|
669
670
|
What a participant writes is marked as theirs everywhere it is kept or sent. The
|
|
670
671
|
author is `"p:<id>"` (`Grant#author`), stamped by chi web from the principal and never taken from a body. Absent
|
|
671
|
-
means the owner:
|
|
672
|
-
|
|
672
|
+
means the owner: a turn or session with no author keys reads as the owner's and gains none on save
|
|
673
|
+
(`spec/turn_author_record_spec.rb`).
|
|
673
674
|
|
|
674
675
|
- **Messages.** A participant's opening message is saved raw with `author`, `author_name` (the name the sidecar gave
|
|
675
676
|
the participant when it was written) and `participant_mode` (`ParticipantMode.of` when `Engine#run_turn` builds it, before any request carries it). Merged input and
|
|
@@ -741,7 +742,7 @@ participant TUI in v1.
|
|
|
741
742
|
worker announces `:participants_changed` (`Engine#announce_participants`: `{participants: [{id, name, kind}],
|
|
742
743
|
joined: […], left: [{…, reason: revoked | expired}]}`) to every UI; its start announces nobody. "Joined" means
|
|
743
744
|
invited: participants have no presence of their own yet. `session_state_snapshot`'s `participants` names who is in
|
|
744
|
-
the review now, so a UI that joins has them. Owner frames only: no participant stream carries them (
|
|
745
|
+
the review now, so a UI that joins has them. Owner frames only: no participant stream carries them (`Web::ParticipantView`).
|
|
745
746
|
- **Lines.** A participant's prompt (`turn_enqueued`'s `author`, an origin's, a queued row's, a saved message's
|
|
746
747
|
`author` / `author_name`) is led by their name (`Formatting#participant_prefix`: `Alex ▸ review>`, cleaned by
|
|
747
748
|
`ParticipantName`, the id when the TUI knows no name), in a colour picked from their id. Each change is one line per
|
|
@@ -7,7 +7,7 @@ sessions, workers, memories and code stay on that machine; this one only sends r
|
|
|
7
7
|
User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
|
|
8
8
|
|
|
9
9
|
**The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback,
|
|
10
|
-
and answer only a request that names their secret from `bridge.json` ([
|
|
10
|
+
and answer only a request that names their secret from `bridge.json` ([bridge.md](bridge.md)).
|
|
11
11
|
chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
|
|
12
12
|
|
|
13
13
|
## The seam: `SessionAccess`
|
|
@@ -58,7 +58,7 @@ Implementation note:
|
|
|
58
58
|
|
|
59
59
|
## The runner's cut (`max_tool_output_chars`)
|
|
60
60
|
|
|
61
|
-
After a tool's own limits, `ToolRunner` caps every result at `max_tool_output_chars` (default `10000`
|
|
61
|
+
After a tool's own limits, `ToolRunner` caps every result but a `memory_read`'s at `max_tool_output_chars` (default `10000`
|
|
62
62
|
characters, both loops). How a longer one is cut is the tool's policy, `ToolOutputCut::POLICIES` (by the
|
|
63
63
|
built-in name; a tool with none gets `:head`):
|
|
64
64
|
|
|
@@ -67,7 +67,26 @@ built-in name; a tool with none gets `:head`):
|
|
|
67
67
|
| `execute` | `:head_tail_file` | a short head, a long tail, one gap line between them; the whole output saved in the session's folder |
|
|
68
68
|
| `task_wait` | `:head_tail_ref` | the same head and tail, the gap line naming the task's log (`output_path`, `Tools::RefText`); nothing saved |
|
|
69
69
|
| `delegate`, `delegate_result` | `:head_tail_ref` | the same, the gap line naming the child's reply file (its `output/<timestamp>.txt`); nothing saved |
|
|
70
|
-
| `read` and every other tool (`task_get`/`task_list`/`task_create`/`task_stop`, `web_fetch`, `
|
|
70
|
+
| `read` and every other tool (`task_get`/`task_list`/`task_create`/`task_stop`, `web_fetch`, `context_read`, plugin and MCP tools) | `:head` | the first chars, then `[cut: N of M chars; read it in parts]` |
|
|
71
|
+
| `memory_read` | `:head`, its own cap | not cut by `max_tool_output_chars`; see below |
|
|
72
|
+
|
|
73
|
+
`memory_read` is the one tool `max_tool_output_chars` does not cap, in any form (a name, a comma list, a blank name for the
|
|
74
|
+
indexes): a memory is read whole, since a model that needs the skill pays for all of it anyway. `ToolRunner#output_cap`
|
|
75
|
+
resolves the cap per call, so both loops get it (they both come through `ToolResponse.run_batch`). Two things can still
|
|
76
|
+
cut a `memory_read` result, both with the same `:head` cut and a line that says where the rest is, since `memory_read`
|
|
77
|
+
has no parts to read:
|
|
78
|
+
|
|
79
|
+
- `memory.read_max_chars` (default `0`: none). A positive value caps the result at that many characters.
|
|
80
|
+
- The overflow guard, with `memory.read_max_chars` at 0: a result over half the model's context window (in characters,
|
|
81
|
+
tokens x `context.chars_per_token`) is cut. A context overflow is a hard failure (`LLM::FailedTurn.keeps_work?` is
|
|
82
|
+
false), so the turn's work rolls back and a retry would repeat the same read. The window is the loop's own:
|
|
83
|
+
`KernelLoop#tool_output_window` (the client's probe, the turn's `window_tokens` setting), or for `LLM::ChatLoop` its
|
|
84
|
+
adapter's model list too (`ToolRunner.new(kernel, window:)`). An unknown window (`ContextWindow` falling back to
|
|
85
|
+
its default) has no guard. It checks this one result, not the context already used.
|
|
86
|
+
|
|
87
|
+
The line is `[cut: N of M chars; the rest is in the memory's .md file in <the memories dir(s) searched>: read it with
|
|
88
|
+
start_line]` ("the memories' .md files" for a comma list or the indexes). `ToolOutputCut::HEAD_CUT` does not match it.
|
|
89
|
+
`read` on a memory's file keeps the `max_tool_output_chars` cut: it has line ranges.
|
|
71
90
|
|
|
72
91
|
`:head_tail_file` is for output whose end matters: a test run's summary, an error, the `exit:` line. The head is
|
|
73
92
|
`execute.cut_head_chars` at most (default `1000`) and never more than a quarter of the room; the tail gets
|